# Log Live

A live admin activity feed that streams new events, with session stats and a pause control.

Log Live 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/log-live

**React**

**Example**

```tsx title="components/blocks/log-live.tsx"
"use client"

import {
  ChartBarIcon,
  ChatCircleIcon,
  CloudIcon,
  GearIcon,
  HandbagIcon,
  PauseIcon,
  PlayIcon,
  ShieldIcon,
  TagIcon,
  UsersIcon,
  WarningIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"

type NavItem = {
  value: string
  label: string
  icon: React.ReactNode
  badge?: React.ReactNode
  group?: string
}

function NavSidebar({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  header,
  footer,
}: {
  items: NavItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  header?: React.ReactNode
  footer?: React.ReactNode
}) {
  const { isMobile, setOpenMobile } = useSidebar()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? items[0]?.value
  )
  const value = valueProp ?? uncontrolledValue
  const groups = [...new Set(items.map((item) => item.group ?? ""))]

  return (
    <Sidebar collapsible="icon">
      {header && (
        <SidebarHeader className="group-data-[collapsible=icon]:hidden">
          {header}
        </SidebarHeader>
      )}
      <SidebarContent>
        {groups.map((group) => (
          <SidebarGroup key={group}>
            {group && <SidebarGroupLabel>{group}</SidebarGroupLabel>}
            <SidebarGroupContent>
              <SidebarMenu>
                {items
                  .filter((item) => (item.group ?? "") === group)
                  .map((item) => (
                    <SidebarMenuItem key={item.value}>
                      <SidebarMenuButton
                        isActive={item.value === value}
                        tooltip={item.label}
                        aria-current={item.value === value ? "page" : undefined}
                        onClick={() => {
                          if (valueProp === undefined) {
                            setUncontrolledValue(item.value)
                          }
                          onValueChange?.(item.value)
                          if (isMobile) setOpenMobile(false)
                        }}
                      >
                        {item.icon}
                        <span>{item.label}</span>
                      </SidebarMenuButton>
                      {item.badge !== undefined && (
                        <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
                      )}
                    </SidebarMenuItem>
                  ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        ))}
      </SidebarContent>
      {footer && (
        <SidebarFooter className="group-data-[collapsible=icon]:hidden">
          {footer}
        </SidebarFooter>
      )}
      <SidebarRail />
    </Sidebar>
  )
}

const nav: NavItem[] = [
  {
    value: "users",
    label: "Users",
    icon: (
      <UsersIcon />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <ShieldIcon />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <ChartBarIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const feed = [
  {
    actor: "Jamie Rivera",
    action: "signed in",
    detail: "Book Pro · San Francisco",
    hue: 250,
  },
  {
    actor: "System",
    action: "finished a backup",
    detail: "12.4 GB · 3 min",
    icon: (
      <CloudIcon className="size-4" />
    ),
  },
  {
    actor: "Morgan Lee",
    action: "updated a product",
    detail: "Phone Air · 240 in stock",
    hue: 30,
    icon: (
      <TagIcon className="size-4" />
    ),
  },
  {
    actor: "Riley Chen",
    action: "answered a ticket",
    detail: "#4821 Screen flickers",
    hue: 150,
    icon: (
      <ChatCircleIcon className="size-4" />
    ),
  },
  {
    actor: "System",
    action: "raised a warning",
    detail: "API latency 820 ms",
    icon: (
      <WarningIcon className="size-4" />
    ),
    warning: true,
  },
  {
    actor: "Jordan Park",
    action: "approved an order",
    detail: "W1052 · $399",
    hue: 100,
    icon: (
      <HandbagIcon className="size-4" />
    ),
  },
]

type Entry = { id: number; time: string; event: (typeof feed)[number] }

export function LogLive() {
  const [entries, setEntries] = React.useState<Entry[]>([])
  const [running, setRunning] = React.useState(true)
  const [count, setCount] = React.useState(0)
  const next = React.useRef(0)

  React.useEffect(() => {
    if (!running) return
    const push = () => {
      const id = next.current++
      const event = feed[id % feed.length]
      if (!event) return
      const time = new Date().toLocaleTimeString("en-US", {
        hour: "numeric",
        minute: "2-digit",
      })
      setCount((current) => current + 1)
      setEntries((current) => [{ id, time, event }, ...current].slice(0, 7))
    }
    if (next.current === 0) push()
    const timer = window.setInterval(push, 2200)
    return () => window.clearInterval(timer)
  }, [running])

  const stats = [
    {
      label: "This session",
      value: `${count} ${count === 1 ? "event" : "events"}`,
    },
    { label: "Active users", value: "14" },
    { label: "Avg. response", value: "182 ms" },
  ]

  return (
    <SidebarProvider>
      <NavSidebar
        items={nav}
        defaultValue="log"
        header={
          <p className="flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
            <span className="font-bold">suiss</span> Admin
          </p>
        }
      />
      <SidebarInset>
        <div className="flex flex-col gap-5 px-5 py-5.5 md:px-7">
          <div className="flex items-start gap-3">
            <SidebarTrigger className="-ms-2 mt-0.5" />
            <div className="flex flex-1 flex-col">
              <h1 className="text-3xl font-semibold tracking-tight">
                Live activity
              </h1>
              <p className="text-sm text-muted-foreground">
                {running ? "Events appear as they happen" : "Paused"}
              </p>
            </div>
            <Button
              size="sm"
              variant="secondary"
              onClick={() => setRunning(!running)}
              className="gap-1.5"
            >
              {running ? (
                <PauseIcon className="size-3 fill-current" />
              ) : (
                <PlayIcon className="size-3 fill-current" />
              )}
              {running ? "Pause" : "Resume"}
            </Button>
          </div>
          <dl className="grid grid-cols-3 overflow-hidden rounded-xl bg-muted">
            {stats.map((stat) => (
              <div
                key={stat.label}
                className="flex min-w-0 flex-col px-3 py-3 not-first:border-s not-first:border-border md:px-4"
              >
                <dt className="truncate text-xs text-muted-foreground">
                  {stat.label}
                </dt>
                <dd className="text-xl font-semibold tabular-nums md:text-2xl">
                  {stat.value}
                </dd>
              </div>
            ))}
          </dl>
          <div className="-mb-2.5 flex items-center gap-2 px-1">
            <h2 className="flex-1 text-sm font-semibold">Recent events</h2>
            {running && (
              <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
                <span className="size-1.75 animate-pulse rounded-full bg-green-500" />
                Live
              </span>
            )}
          </div>
          <ul
            aria-live="polite"
            aria-relevant="additions"
            className="overflow-hidden rounded-2xl bg-muted"
          >
            {entries.map(({ id, time, event }) => (
              <li
                key={id}
                className="relative flex min-h-11 items-center gap-3 px-4 py-2 transition-[opacity,translate] duration-700 ease-[cubic-bezier(0.32,0.72,0,1)] not-first:before:absolute not-first:before:inset-e-0 not-first:before:inset-s-14 not-first:before:top-0 not-first:before:h-px not-first:before:bg-border starting:-translate-y-2.5 starting:opacity-0 motion-reduce:transition-none"
              >
                {event.hue ? (
                  <Avatar className="size-7">
                    <AvatarFallback
                      style={{
                        background: `linear-gradient(160deg, oklch(0.78 0.1 ${event.hue}), oklch(0.6 0.14 ${event.hue + 30}))`,
                      }}
                      className="text-xs text-white"
                    >
                      {event.actor
                        .split(" ")
                        .map((part) => part[0])
                        .join("")}
                    </AvatarFallback>
                  </Avatar>
                ) : (
                  <span
                    data-warning={event.warning ? "" : undefined}
                    className="flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-zinc-500 text-white data-warning:bg-orange-500"
                  >
                    {event.icon}
                  </span>
                )}
                <span className="flex min-w-0 flex-1 flex-col">
                  <span className="truncate text-sm">
                    <strong className="font-semibold">{event.actor}</strong>{" "}
                    {event.action}
                  </span>
                  <span className="truncate text-xs text-muted-foreground">
                    {event.detail}
                  </span>
                </span>
                <span className="text-sm text-muted-foreground tabular-nums">
                  {time}
                </span>
              </li>
            ))}
          </ul>
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

A live activity feed that streams new events to the top of a list, with session stats above and a Pause or Resume button. People appear with gradient avatars and system events with icons, with warnings tinted orange. It sits in a `Sidebar` with admin navigation that opens as a sheet from the trigger on mobile. Use it for real-time monitoring, deploy logs or live order feeds.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/log-live
```

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 log-live
```

### Manual

1. Install the dependencies:

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

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Sidebar](https://ui.suiss.app/docs/components/sidebar).

3. Copy the source into `components/blocks/log-live.tsx`:

```tsx title="components/blocks/log-live.tsx"
"use client"

import {
  ChartBarIcon,
  ChatCircleIcon,
  CloudIcon,
  GearIcon,
  HandbagIcon,
  PauseIcon,
  PlayIcon,
  ShieldIcon,
  TagIcon,
  UsersIcon,
  WarningIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"

type NavItem = {
  value: string
  label: string
  icon: React.ReactNode
  badge?: React.ReactNode
  group?: string
}

function NavSidebar({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  header,
  footer,
}: {
  items: NavItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  header?: React.ReactNode
  footer?: React.ReactNode
}) {
  const { isMobile, setOpenMobile } = useSidebar()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? items[0]?.value
  )
  const value = valueProp ?? uncontrolledValue
  const groups = [...new Set(items.map((item) => item.group ?? ""))]

  return (
    <Sidebar collapsible="icon">
      {header && (
        <SidebarHeader className="group-data-[collapsible=icon]:hidden">
          {header}
        </SidebarHeader>
      )}
      <SidebarContent>
        {groups.map((group) => (
          <SidebarGroup key={group}>
            {group && <SidebarGroupLabel>{group}</SidebarGroupLabel>}
            <SidebarGroupContent>
              <SidebarMenu>
                {items
                  .filter((item) => (item.group ?? "") === group)
                  .map((item) => (
                    <SidebarMenuItem key={item.value}>
                      <SidebarMenuButton
                        isActive={item.value === value}
                        tooltip={item.label}
                        aria-current={item.value === value ? "page" : undefined}
                        onClick={() => {
                          if (valueProp === undefined) {
                            setUncontrolledValue(item.value)
                          }
                          onValueChange?.(item.value)
                          if (isMobile) setOpenMobile(false)
                        }}
                      >
                        {item.icon}
                        <span>{item.label}</span>
                      </SidebarMenuButton>
                      {item.badge !== undefined && (
                        <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
                      )}
                    </SidebarMenuItem>
                  ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        ))}
      </SidebarContent>
      {footer && (
        <SidebarFooter className="group-data-[collapsible=icon]:hidden">
          {footer}
        </SidebarFooter>
      )}
      <SidebarRail />
    </Sidebar>
  )
}

const nav: NavItem[] = [
  {
    value: "users",
    label: "Users",
    icon: (
      <UsersIcon />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <ShieldIcon />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <ChartBarIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const feed = [
  {
    actor: "Jamie Rivera",
    action: "signed in",
    detail: "Book Pro · San Francisco",
    hue: 250,
  },
  {
    actor: "System",
    action: "finished a backup",
    detail: "12.4 GB · 3 min",
    icon: (
      <CloudIcon className="size-4" />
    ),
  },
  {
    actor: "Morgan Lee",
    action: "updated a product",
    detail: "Phone Air · 240 in stock",
    hue: 30,
    icon: (
      <TagIcon className="size-4" />
    ),
  },
  {
    actor: "Riley Chen",
    action: "answered a ticket",
    detail: "#4821 Screen flickers",
    hue: 150,
    icon: (
      <ChatCircleIcon className="size-4" />
    ),
  },
  {
    actor: "System",
    action: "raised a warning",
    detail: "API latency 820 ms",
    icon: (
      <WarningIcon className="size-4" />
    ),
    warning: true,
  },
  {
    actor: "Jordan Park",
    action: "approved an order",
    detail: "W1052 · $399",
    hue: 100,
    icon: (
      <HandbagIcon className="size-4" />
    ),
  },
]

type Entry = { id: number; time: string; event: (typeof feed)[number] }

export function LogLive() {
  const [entries, setEntries] = React.useState<Entry[]>([])
  const [running, setRunning] = React.useState(true)
  const [count, setCount] = React.useState(0)
  const next = React.useRef(0)

  React.useEffect(() => {
    if (!running) return
    const push = () => {
      const id = next.current++
      const event = feed[id % feed.length]
      if (!event) return
      const time = new Date().toLocaleTimeString("en-US", {
        hour: "numeric",
        minute: "2-digit",
      })
      setCount((current) => current + 1)
      setEntries((current) => [{ id, time, event }, ...current].slice(0, 7))
    }
    if (next.current === 0) push()
    const timer = window.setInterval(push, 2200)
    return () => window.clearInterval(timer)
  }, [running])

  const stats = [
    {
      label: "This session",
      value: `${count} ${count === 1 ? "event" : "events"}`,
    },
    { label: "Active users", value: "14" },
    { label: "Avg. response", value: "182 ms" },
  ]

  return (
    <SidebarProvider>
      <NavSidebar
        items={nav}
        defaultValue="log"
        header={
          <p className="flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
            <span className="font-bold">suiss</span> Admin
          </p>
        }
      />
      <SidebarInset>
        <div className="flex flex-col gap-5 px-5 py-5.5 md:px-7">
          <div className="flex items-start gap-3">
            <SidebarTrigger className="-ms-2 mt-0.5" />
            <div className="flex flex-1 flex-col">
              <h1 className="text-3xl font-semibold tracking-tight">
                Live activity
              </h1>
              <p className="text-sm text-muted-foreground">
                {running ? "Events appear as they happen" : "Paused"}
              </p>
            </div>
            <Button
              size="sm"
              variant="secondary"
              onClick={() => setRunning(!running)}
              className="gap-1.5"
            >
              {running ? (
                <PauseIcon className="size-3 fill-current" />
              ) : (
                <PlayIcon className="size-3 fill-current" />
              )}
              {running ? "Pause" : "Resume"}
            </Button>
          </div>
          <dl className="grid grid-cols-3 overflow-hidden rounded-xl bg-muted">
            {stats.map((stat) => (
              <div
                key={stat.label}
                className="flex min-w-0 flex-col px-3 py-3 not-first:border-s not-first:border-border md:px-4"
              >
                <dt className="truncate text-xs text-muted-foreground">
                  {stat.label}
                </dt>
                <dd className="text-xl font-semibold tabular-nums md:text-2xl">
                  {stat.value}
                </dd>
              </div>
            ))}
          </dl>
          <div className="-mb-2.5 flex items-center gap-2 px-1">
            <h2 className="flex-1 text-sm font-semibold">Recent events</h2>
            {running && (
              <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
                <span className="size-1.75 animate-pulse rounded-full bg-green-500" />
                Live
              </span>
            )}
          </div>
          <ul
            aria-live="polite"
            aria-relevant="additions"
            className="overflow-hidden rounded-2xl bg-muted"
          >
            {entries.map(({ id, time, event }) => (
              <li
                key={id}
                className="relative flex min-h-11 items-center gap-3 px-4 py-2 transition-[opacity,translate] duration-700 ease-[cubic-bezier(0.32,0.72,0,1)] not-first:before:absolute not-first:before:inset-e-0 not-first:before:inset-s-14 not-first:before:top-0 not-first:before:h-px not-first:before:bg-border starting:-translate-y-2.5 starting:opacity-0 motion-reduce:transition-none"
              >
                {event.hue ? (
                  <Avatar className="size-7">
                    <AvatarFallback
                      style={{
                        background: `linear-gradient(160deg, oklch(0.78 0.1 ${event.hue}), oklch(0.6 0.14 ${event.hue + 30}))`,
                      }}
                      className="text-xs text-white"
                    >
                      {event.actor
                        .split(" ")
                        .map((part) => part[0])
                        .join("")}
                    </AvatarFallback>
                  </Avatar>
                ) : (
                  <span
                    data-warning={event.warning ? "" : undefined}
                    className="flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-zinc-500 text-white data-warning:bg-orange-500"
                  >
                    {event.icon}
                  </span>
                )}
                <span className="flex min-w-0 flex-1 flex-col">
                  <span className="truncate text-sm">
                    <strong className="font-semibold">{event.actor}</strong>{" "}
                    {event.action}
                  </span>
                  <span className="truncate text-xs text-muted-foreground">
                    {event.detail}
                  </span>
                </span>
                <span className="text-sm text-muted-foreground tabular-nums">
                  {time}
                </span>
              </li>
            ))}
          </ul>
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { LogLive } from "@/components/blocks/log-live"
```

```tsx
<LogLive />
```

The demo pushes the next item from the `feed` sample every 2.2 seconds; replace that interval with a subscription to your event stream, such as a WebSocket or server-sent events. Each event has an actor, action, detail, either an avatar hue or an icon, and an optional warning flag. The list keeps only the seven newest events, and the Active users and Avg. response stats are hard-coded.

## Notes

Every new event is announced to screen readers, which can get noisy on a busy stream; consider throttling or announcing a summary instead.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/log-live.tsx"
import { cn } from "cn"
import {
  ChartBarIcon,
  ChatCircleIcon,
  CloudIcon,
  GearIcon,
  HandbagIcon,
  PauseIcon,
  type Icon as PhosphorIcon,
  PlayIcon,
  ShieldIcon,
  TagIcon,
  UsersIcon,
  WarningIcon,
} from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInUp,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Header,
  HeaderActions,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

type FeedEvent = {
  actor: string
  action: string
  detail: string
  tint?: string
  icon?: PhosphorIcon
  warning?: boolean
}

const FEED: FeedEvent[] = [
  {
    actor: "Jamie Rivera",
    action: "signed in",
    detail: "Book Pro · San Francisco",
    tint: "bg-indigo-500",
  },
  {
    actor: "System",
    action: "finished a backup",
    detail: "12.4 GB · 3 min",
    icon: CloudIcon,
  },
  {
    actor: "Morgan Lee",
    action: "updated a product",
    detail: "Phone Air · 240 in stock",
    tint: "bg-orange-500",
    icon: TagIcon,
  },
  {
    actor: "Riley Chen",
    action: "answered a ticket",
    detail: "#4821 Screen flickers",
    tint: "bg-green-500",
    icon: ChatCircleIcon,
  },
  {
    actor: "System",
    action: "raised a warning",
    detail: "API latency 820 ms",
    icon: WarningIcon,
    warning: true,
  },
  {
    actor: "Jordan Park",
    action: "approved an order",
    detail: "W1052 · $399",
    tint: "bg-teal-400",
    icon: HandbagIcon,
  },
]

type Entry = { id: number; time: string; event: FeedEvent }

const clock = (date: Date) => {
  const hours = date.getHours()
  const minutes = String(date.getMinutes()).padStart(2, "0")
  return `${hours % 12 || 12}:${minutes} ${hours < 12 ? "AM" : "PM"}`
}

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View className="flex-1 gap-2 px-4 pt-13">
      <Text role="heading" className="text-3xl font-bold">
        {label}
      </Text>
      <Text className="text-lg text-muted-foreground">
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function LiveDot() {
  const opacity = useSharedValue(1)

  React.useEffect(() => {
    opacity.value = withRepeat(
      withTiming(0.4, {
        duration: 1000,
        easing: Easing.bezier(0.4, 0, 0.6, 1),
      }),
      -1,
      true,
      undefined,
      ReduceMotion.System
    )
    return () => cancelAnimation(opacity)
  }, [opacity])

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

  return (
    <AnimatedView
      className="size-1.75 rounded-full bg-green-500"
      style={animatedStyle}
    />
  )
}

function EntryRow({ entry, first }: { entry: Entry; first: boolean }) {
  const { event } = entry
  return (
    <AnimatedView
      entering={FadeInUp.duration(700)
        .easing(Easing.bezier(0.32, 0.72, 0, 1))
        .reduceMotion(ReduceMotion.System)}
    >
      {first ? null : <View className="ms-14 h-px bg-border" />}
      <View className="min-h-11 flex-row items-center gap-3 px-4 py-2">
        {event.tint ? (
          <Avatar size="sm" className="size-7">
            <AvatarFallback className={event.tint}>
              {initials(event.actor)}
            </AvatarFallback>
          </Avatar>
        ) : (
          <View
            className={cn(
              "size-7 shrink-0 items-center justify-center rounded-[7px]",
              event.warning ? "bg-orange-500" : "bg-zinc-500"
            )}
          >
            {event.icon ? (
              <Icon
                as={event.icon}
                weight="bold"
                className="size-4 text-primary-foreground"
              />
            ) : null}
          </View>
        )}
        <View className="min-w-0 flex-1">
          <Text numberOfLines={1} className="text-sm">
            <Text className="text-sm font-semibold">{event.actor}</Text>
            {` ${event.action}`}
          </Text>
          <Text numberOfLines={1} className="text-xs text-muted-foreground">
            {event.detail}
          </Text>
        </View>
        <Text className="text-sm text-muted-foreground tabular-nums">
          {entry.time}
        </Text>
      </View>
    </AnimatedView>
  )
}

function LogLive({
  onRunningChange,
  onNavigate,
}: {
  onRunningChange?: (running: boolean) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("log")
  const [entries, setEntries] = React.useState<Entry[]>([])
  const [running, setRunning] = React.useState(true)
  const [count, setCount] = React.useState(0)
  const next = React.useRef(0)
  const scroll = useHeaderScroll()

  React.useEffect(() => {
    if (!running) return
    const push = () => {
      const id = next.current++
      const event = FEED[id % FEED.length]
      if (!event) return
      setCount((current) => current + 1)
      setEntries((current) =>
        [{ id, time: clock(new Date()), event }, ...current].slice(0, 7)
      )
    }
    if (next.current === 0) push()
    const timer = setInterval(push, 2200)
    return () => clearInterval(timer)
  }, [running])

  const toggle = () => {
    onRunningChange?.(!running)
    setRunning(!running)
  }

  const stats = [
    {
      label: "This session",
      value: `${count} ${count === 1 ? "event" : "events"}`,
    },
    { label: "Active users", value: "14" },
    { label: "Avg. response", value: "182 ms" },
  ]

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "log" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Live activity</HeaderTitle>
            <HeaderActions>
              <Button variant="secondary" size="sm" onPress={toggle}>
                <Icon
                  as={running ? PauseIcon : PlayIcon}
                  weight="fill"
                  className="size-3"
                />
                <Text>{running ? "Pause" : "Resume"}</Text>
              </Button>
            </HeaderActions>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle
              subtitle={running ? "Events appear as they happen" : "Paused"}
            >
              Live activity
            </HeaderLargeTitle>
            <View className="gap-5 px-4 pt-3 pb-8">
              <View className="flex-row overflow-hidden rounded-xl bg-muted">
                {stats.map((stat, index) => (
                  <View
                    key={stat.label}
                    accessible
                    className={cn(
                      "flex-1 px-3 py-3",
                      index > 0 && "border-s border-border"
                    )}
                  >
                    <Text
                      numberOfLines={1}
                      className="text-xs text-muted-foreground"
                    >
                      {stat.label}
                    </Text>
                    <Text
                      numberOfLines={1}
                      adjustsFontSizeToFit
                      className="text-xl font-semibold tabular-nums"
                    >
                      {stat.value}
                    </Text>
                  </View>
                ))}
              </View>
              <View className="-mb-2.5 flex-row items-center gap-2 px-1">
                <Text role="heading" className="flex-1 text-sm font-semibold">
                  Recent events
                </Text>
                {running ? (
                  <View className="flex-row items-center gap-1.5">
                    <LiveDot />
                    <Text className="text-xs text-muted-foreground">Live</Text>
                  </View>
                ) : null}
              </View>
              <View
                accessibilityLiveRegion="polite"
                className="overflow-hidden rounded-2xl bg-muted"
              >
                {entries.map((entry, index) => (
                  <EntryRow key={entry.id} entry={entry} first={index === 0} />
                ))}
              </View>
            </View>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Activity" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { LogLive }
```

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

public struct LogLive: View {
  struct Event {
    let actor: String
    let action: String
    let detail: String
    var tint: Color?
    var icon: Image?
    var warning = false
  }

  struct Entry: Identifiable {
    let id: Int
    let time: Date
    let event: Event
  }

  static var feed: [Event] {
    [
      Event(actor: "Jamie Rivera", action: "signed in", detail: "Book Pro · San Francisco", tint: Palette.indigo500),
      Event(actor: "System", action: "finished a backup", detail: "12.4 GB · 3 min", icon: Ph.cloud.bold),
      Event(actor: "Morgan Lee", action: "updated a product", detail: "Phone Air · 240 in stock", tint: Palette.orange500, icon: Ph.tag.bold),
      Event(actor: "Riley Chen", action: "answered a ticket", detail: "#4821 Screen flickers", tint: Palette.green500, icon: Ph.chatCircle.bold),
      Event(actor: "System", action: "raised a warning", detail: "API latency 820 ms", icon: Ph.warning.bold, warning: true),
      Event(actor: "Jordan Park", action: "approved an order", detail: "W1052 · $399", tint: Palette.teal400, icon: Ph.handbag.bold),
    ]
  }

  static let limit = 7

  static func entry(_ id: Int, time: Date) -> Entry {
    Entry(id: id, time: time, event: feed[id % feed.count])
  }

  static func pushed(_ entries: [Entry], id: Int, time: Date) -> [Entry] {
    Array(([entry(id, time: time)] + entries).prefix(limit))
  }

  private var onNavigate: ((String) -> Void)?
  private var onRunningChange: ((Bool) -> Void)?
  private let ticking: Bool
  @State private var entries: [Entry]
  @State private var running: Bool
  @State private var count: Int
  @State private var pulse = false
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onRunningChange: ((_ running: Bool) -> Void)? = nil,
    onNavigate: ((_ section: String) -> Void)? = nil
  ) {
    self.init(count: 0, running: true)
    self.onRunningChange = onRunningChange
    self.onNavigate = onNavigate
  }

  init(count: Int, running: Bool, now: Date = .now, ticking: Bool = true) {
    var entries: [Entry] = []
    for id in 0..<count {
      entries = Self.pushed(entries, id: id, time: now.addingTimeInterval(Double(id - count + 1) * 60))
    }
    _entries = State(initialValue: entries)
    _running = State(initialValue: running)
    _count = State(initialValue: count)
    self.ticking = ticking
  }

  public var body: some View {
    LogLiveShell(home: "log", onNavigate: onNavigate) {
      page
    }
  }

  @ViewBuilder private var page: some View {
    Screen {
      HeaderScrollView {
        HeaderLargeTitle("Live activity", subtitle: running ? "Events appear as they happen" : "Paused")
        VStack(alignment: .leading, spacing: 20) {
          stats
          VStack(alignment: .leading, spacing: 10) {
            HStack(spacing: 8) {
              Text("Recent events")
                .scaledFont(.sm, weight: .semibold)
                .frame(maxWidth: .infinity, alignment: .leading)
                .accessibilityAddTraits(.isHeader)
              if running {
                HStack(spacing: 6) {
                  Circle()
                    .fill(Palette.green500)
                    .frame(width: 7, height: 7)
                    .opacity(pulse ? 0.35 : 1)
                  Text("Live")
                    .scaledFont(.xs)
                    .foregroundStyle(EsuissColor.mutedForeground)
                }
                .transition(.opacity)
                .onAppear {
                  guard !reduceMotion else { return }
                  withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { pulse = true }
                }
                .onDisappear { pulse = false }
              }
            }
            .padding(.horizontal, 4)
            VStack(spacing: 0) {
              ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
                LogLiveRow(entry: entry)
                  .overlay(alignment: .top) {
                    if index > 0 {
                      EsuissColor.border.frame(height: 1).padding(.leading, 56)
                    }
                  }
                  .transition(.asymmetric(insertion: .opacity.combined(with: .offset(y: -10)), removal: .opacity))
              }
            }
            .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
            .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
          }
        }
        .padding(.horizontal, 16)
        .padding(.top, 8)
        .padding(.bottom, 24)
      } header: {
        Header {
          HeaderTitle("Live activity")
        } trailing: {
          Button {
            onRunningChange?(!running)
            running.toggle()
          } label: {
            SwiftUI.Label {
              Text(running ? "Pause" : "Resume")
            } icon: {
              (running ? Ph.pause.fill : Ph.play.fill).frame(width: 12, height: 12)
            }
          }
          .buttonStyle(.variant(.secondary, size: .sm))
        }
      }
    }
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.6), value: running)
    .task(id: running) {
      guard running, ticking else { return }
      if count == 0 { push() }
      while !Task.isCancelled {
        try? await Task.sleep(for: .milliseconds(2200))
        guard !Task.isCancelled else { break }
        push()
      }
    }
  }

  private var stats: some View {
    let items: [(LocalizedStringKey, Text)] = [
      ("This session", Text(count == 1 ? "\(count) event" : "\(count) events")),
      ("Active users", Text(verbatim: "14")),
      ("Avg. response", Text(verbatim: "182 ms")),
    ]
    return HStack(spacing: 0) {
      ForEach(Array(items.enumerated()), id: \.offset) { index, item in
        VStack(alignment: .leading, spacing: 2) {
          Text(item.0)
            .scaledFont(.xs)
            .foregroundStyle(EsuissColor.mutedForeground)
          item.1
            .scaledFont(.xl, weight: .semibold)
            .monospacedDigit()
            .contentTransition(.numericText())
        }
        .lineLimit(1)
        .minimumScaleFactor(0.7)
        .padding(.horizontal, 14)
        .padding(.vertical, 12)
        .frame(maxWidth: .infinity, alignment: .leading)
        .overlay(alignment: .leading) {
          if index > 0 {
            EsuissColor.border.frame(width: 1)
          }
        }
        .accessibilityElement(children: .combine)
      }
    }
    .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
  }

  private func push() {
    let id = count
    withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.7)) {
      entries = Self.pushed(entries, id: id, time: .now)
      count += 1
    }
  }
}

private struct LogLiveRow: View {
  let entry: LogLive.Entry

  var body: some View {
    let event = entry.event
    HStack(spacing: 12) {
      Group {
        if let tint = event.tint {
          Text(verbatim: event.actor.split(separator: " ").compactMap(\.first).map(String.init).joined())
            .scaledFont(.xs, weight: .semibold)
            .lineLimit(1)
            .minimumScaleFactor(0.5)
            .foregroundStyle(EsuissColor.primaryForeground)
            .frame(width: 28, height: 28)
            .background(tint, in: Circle())
        } else {
          (event.icon ?? Ph.gear.bold)
            .frame(width: 16, height: 16)
            .foregroundStyle(EsuissColor.primaryForeground)
            .frame(width: 28, height: 28)
            .background(event.warning ? Palette.orange500 : Palette.zinc500, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
        }
      }
      .accessibilityHidden(true)
      VStack(alignment: .leading, spacing: 1) {
        Text("\(Text(verbatim: event.actor).fontWeight(.semibold)) \(Text(LocalizedStringKey(event.action)))")
          .scaledFont(.sm)
          .lineLimit(1)
        Text(verbatim: event.detail)
          .scaledFont(.xs)
          .foregroundStyle(EsuissColor.mutedForeground)
          .lineLimit(1)
      }
      .frame(maxWidth: .infinity, alignment: .leading)
      Text(entry.time, format: .dateTime.hour().minute())
        .scaledFont(.sm)
        .monospacedDigit()
        .foregroundStyle(EsuissColor.mutedForeground)
    }
    .foregroundStyle(EsuissColor.foreground)
    .padding(.horizontal, 16)
    .padding(.vertical, 8)
    .frame(minHeight: 44)
    .accessibilityElement(children: .combine)
  }
}

private struct LogLiveShell<Content: View>: View {
  let home: String
  let onNavigate: ((String) -> Void)?
  let content: Content

  @State private var tab: String

  init(home: String, onNavigate: ((String) -> Void)?, @ViewBuilder content: () -> Content) {
    self.home = home
    self.onNavigate = onNavigate
    self.content = content()
    _tab = State(initialValue: home)
  }

  private static var sections: [(value: String, label: LocalizedStringKey, icon: Image, active: Image)] {
    [
      ("users", "Users", Ph.users.regular, Ph.users.fill),
      ("roles", "Roles", Ph.shield.regular, Ph.shield.fill),
      ("log", "Activity", Ph.chartBar.regular, Ph.chartBar.fill),
      ("settings", "Settings", Ph.gear.regular, Ph.gear.fill),
    ]
  }

  var body: some View {
    let selection = Binding<String>(
      get: { tab },
      set: { value in
        guard value != tab else { return }
        onNavigate?(value)
        tab = value
      }
    )
    Screen {
      ZStack {
        content
          .opacity(tab == home ? 1 : 0)
          .allowsHitTesting(tab == home)
          .accessibilityHidden(tab != home)
        if tab != home {
          Empty {
            EmptyHeader {
              EmptyDescription("This section isn't part of the sample. Choose \(Text(Self.sections.first { $0.value == home }?.label ?? "")).")
            }
          }
          .background(EsuissColor.background)
        }
      }
      .frame(maxHeight: .infinity)
      TabBar(selection: selection) {
        ForEach(Self.sections, id: \.value) { section in
          TabBarItem(section.label, value: section.value, icon: section.icon, activeIcon: section.active)
        }
      }
    }
  }
}
```

```kt title="kotlin/blocks/LogLive.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.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.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.ChatCircle
import com.adamglin.phosphoricons.bold.Cloud
import com.adamglin.phosphoricons.bold.Handbag
import com.adamglin.phosphoricons.bold.Tag
import com.adamglin.phosphoricons.bold.Warning
import com.adamglin.phosphoricons.fill.ChartBar as FillChartBar
import com.adamglin.phosphoricons.fill.Gear as FillGear
import com.adamglin.phosphoricons.fill.Pause
import com.adamglin.phosphoricons.fill.Play
import com.adamglin.phosphoricons.fill.Shield as FillShield
import com.adamglin.phosphoricons.fill.Users as FillUsers
import com.adamglin.phosphoricons.regular.ChartBar
import com.adamglin.phosphoricons.regular.Gear
import com.adamglin.phosphoricons.regular.Shield
import com.adamglin.phosphoricons.regular.Users
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderActions
import com.esuiss.ui.components.HeaderLargeTitle
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemActions
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemGroup
import com.esuiss.ui.components.ItemGroupVariant
import com.esuiss.ui.components.ItemMedia
import com.esuiss.ui.components.ItemSeparator
import com.esuiss.ui.components.ItemSize
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.TabBar
import com.esuiss.ui.components.TabBarItem
import com.esuiss.ui.components.rememberHeaderState
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

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

private class LiveFeedEvent(
    val actor: String,
    val action: String,
    val detail: String,
    val hue: Int = 0,
    val icon: ImageVector? = null,
    val warning: Boolean = false,
)

private val LiveFeed = listOf(
    LiveFeedEvent("Jamie Rivera", "signed in", "Book Pro · San Francisco", hue = 250),
    LiveFeedEvent("System", "finished a backup", "12.4 GB · 3 min", icon = PhosphorIcons.Bold.Cloud),
    LiveFeedEvent("Morgan Lee", "updated a product", "Phone Air · 240 in stock", hue = 30, icon = PhosphorIcons.Bold.Tag),
    LiveFeedEvent("Riley Chen", "answered a ticket", "#4821 Screen flickers", hue = 150, icon = PhosphorIcons.Bold.ChatCircle),
    LiveFeedEvent("System", "raised a warning", "API latency 820 ms", icon = PhosphorIcons.Bold.Warning, warning = true),
    LiveFeedEvent("Jordan Park", "approved an order", "W1052 · \$399", hue = 100, icon = PhosphorIcons.Bold.Handbag),
)

private class LiveEntry(val id: Int, val time: String, val event: LiveFeedEvent)

private fun liveTint(colors: Colors, hue: Int): Color = when (hue) {
    250 -> Palette.indigo500
    30 -> Palette.orange500
    150 -> Palette.green500
    else -> Palette.teal500
}

@Composable
fun LogLive(
    modifier: Modifier = Modifier,
    onRunningChange: (running: Boolean) -> Unit = {},
    onNavigate: (section: String) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    val header = rememberHeaderState()
    val entries = remember { mutableStateListOf<LiveEntry>() }
    var running by rememberSaveable { mutableStateOf(true) }
    var count by remember { mutableIntStateOf(0) }
    var next by remember { mutableIntStateOf(0) }
    val format = remember { SimpleDateFormat("h:mm a", Locale.US) }

    fun push() {
        val id = next++
        val event = LiveFeed[id % LiveFeed.size]
        count += 1
        entries.add(0, LiveEntry(id, format.format(Date()), event))
        while (entries.size > 7) entries.removeAt(entries.lastIndex)
    }

    LaunchedEffect(running) {
        if (!running) return@LaunchedEffect
        if (next == 0) push()
        while (true) {
            delay(2200)
            push()
        }
    }

    val stats = listOf(
        "This session" to "$count ${if (count == 1) "event" else "events"}",
        "Active users" to "14",
        "Avg. response" to "182 ms",
    )

    Screen(modifier, edges = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right)) {
        AdminTabs("log", onNavigate) {
            Column(
                Modifier
                    .fillMaxSize()
                    .nestedScroll(header.nestedScrollConnection),
            ) {
                Header(state = header) {
                    HeaderTitle("Live activity")
                    HeaderLargeTitle("Live activity", subtitle = if (running) "Events appear as they happen" else "Paused")
                    HeaderActions {
                        Button(
                            onClick = {
                                running = !running
                                onRunningChange(running)
                            },
                            size = ButtonSize.Sm,
                            variant = ButtonVariant.Secondary,
                        ) {
                            Icon(if (running) PhosphorIcons.Fill.Pause else PhosphorIcons.Fill.Play, contentDescription = null, size = 12.dp)
                            BasicText(if (running) "Pause" else "Resume", style = LocalContentStyle.current)
                        }
                    }
                }
                Column(
                    Modifier
                        .weight(1f)
                        .fillMaxWidth()
                        .verticalScroll(rememberScrollState())
                        .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 32.dp),
                    verticalArrangement = Arrangement.spacedBy(20.dp),
                ) {
                    Row(
                        Modifier
                            .fillMaxWidth()
                            .height(IntrinsicSize.Min)
                            .clip(RoundedCornerShape(Radius.xl))
                            .background(colors.muted),
                    ) {
                        stats.forEachIndexed { index, (label, value) ->
                            if (index > 0) {
                                Box(
                                    Modifier
                                        .width(1.dp)
                                        .fillMaxHeight()
                                        .background(colors.border),
                                )
                            }
                            Column(
                                Modifier
                                    .weight(1f)
                                    .padding(horizontal = 14.dp, vertical = 12.dp),
                            ) {
                                BasicText(label, style = Typography.xs.copy(color = colors.mutedForeground), maxLines = 1)
                                BasicText(
                                    value,
                                    style = Typography.lg.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                                    maxLines = 1,
                                )
                            }
                        }
                    }
                    Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
                        Row(Modifier.padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) {
                            BasicText(
                                "Recent events",
                                modifier = Modifier
                                    .weight(1f)
                                    .semantics { heading() },
                                style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                            )
                            if (running) LivePulse()
                        }
                        ItemGroup(
                            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                            variant = ItemGroupVariant.Inset,
                        ) {
                            entries.forEachIndexed { index, entry ->
                                key(entry.id) {
                                    if (index > 0) ItemSeparator(Modifier.padding(start = 40.dp))
                                    LiveRow(entry)
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun LivePulse() {
    val colors = EsuissTheme.colors
    val transition = rememberInfiniteTransition(label = "live")
    val alpha by transition.animateFloat(
        initialValue = 1f,
        targetValue = 0.4f,
        animationSpec = infiniteRepeatable(tween(1000), RepeatMode.Reverse),
        label = "pulse",
    )
    Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
        Box(
            Modifier
                .size(7.dp)
                .graphicsLayer { this.alpha = alpha }
                .background(Palette.green500, CircleShape),
        )
        BasicText("Live", style = Typography.xs.copy(color = colors.mutedForeground))
    }
}

@Composable
private fun LiveRow(entry: LiveEntry) {
    val colors = EsuissTheme.colors
    val event = entry.event
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(700, easing = LiveRevealEasing)) }
    Item(
        modifier = Modifier.graphicsLayer {
            alpha = appear.value
            translationY = (appear.value - 1f) * 10.dp.toPx()
        },
        size = ItemSize.Sm,
        role = null,
    ) {
        ItemMedia {
            if (event.hue != 0) {
                Box(
                    Modifier
                        .size(28.dp)
                        .background(liveTint(colors, event.hue), CircleShape),
                    contentAlignment = Alignment.Center,
                ) {
                    BasicText(
                        event.actor.split(" ").mapNotNull { it.firstOrNull() }.joinToString(""),
                        style = Typography.xs.copy(color = Palette.white, fontWeight = FontWeight.Medium),
                    )
                }
            } else {
                Box(
                    Modifier
                        .size(28.dp)
                        .background(if (event.warning) Palette.orange500 else Palette.zinc500, RoundedCornerShape(7.dp)),
                    contentAlignment = Alignment.Center,
                ) {
                    event.icon?.let { Icon(it, contentDescription = null, size = 16.dp, tint = Palette.white) }
                }
            }
        }
        ItemContent {
            BasicText(
                buildAnnotatedString {
                    withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { append(event.actor) }
                    append(" ${event.action}")
                },
                style = Typography.sm.copy(color = colors.foreground),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
            BasicText(
                event.detail,
                style = Typography.xs.copy(color = colors.mutedForeground),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
        ItemActions {
            BasicText(entry.time, style = Typography.sm.copy(color = colors.mutedForeground, fontFeatureSettings = "tnum"))
        }
    }
}

private val AdminSections = listOf("users" to "Users", "roles" to "Roles", "log" to "Activity", "settings" to "Settings")

@Composable
private fun AdminTabs(home: String, onNavigate: (String) -> Unit, content: @Composable () -> Unit) {
    val colors = EsuissTheme.colors
    var tab by rememberSaveable { mutableStateOf(home) }
    Column(Modifier.fillMaxSize()) {
        Box(
            Modifier
                .weight(1f)
                .fillMaxWidth(),
        ) {
            if (tab == home) {
                content()
            } else {
                BasicText(
                    "This section isn't part of the sample. Choose ${AdminSections.first { it.first == home }.second}.",
                    modifier = Modifier
                        .align(Alignment.Center)
                        .padding(32.dp),
                    style = Typography.base.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                )
            }
        }
        TabBar(
            value = tab,
            onValueChange = {
                onNavigate(it)
                tab = it
            },
        ) {
            TabBarItem("users", "Users", PhosphorIcons.Regular.Users, selectedIcon = PhosphorIcons.Fill.FillUsers)
            TabBarItem("roles", "Roles", PhosphorIcons.Regular.Shield, selectedIcon = PhosphorIcons.Fill.FillShield)
            TabBarItem("log", "Activity", PhosphorIcons.Regular.ChartBar, selectedIcon = PhosphorIcons.Fill.FillChartBar)
            TabBarItem("settings", "Settings", PhosphorIcons.Regular.Gear, selectedIcon = PhosphorIcons.Fill.FillGear)
        }
    }
}
```

An admin screen with a collapsing `Header`, three stats for the session, and a list of recent events with an `Avatar` or icon for each actor. A timer adds the next sample event every 2.2 seconds and keeps the seven newest, with a pulsing Live dot while it runs. The header button pauses and resumes the feed. A `TabBar` at the bottom switches between admin sections; only Activity has content in the sample.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/log-live
```

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 log-live
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [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/log-live.tsx`:

- `uniwind/components/blocks/log-live.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [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/log-live.tsx`:

```tsx title="nativewind/components/blocks/log-live.tsx"
import { cn } from "cn"
import {
  ChartBarIcon,
  ChatCircleIcon,
  CloudIcon,
  GearIcon,
  HandbagIcon,
  PauseIcon,
  type Icon as PhosphorIcon,
  PlayIcon,
  ShieldIcon,
  TagIcon,
  UsersIcon,
  WarningIcon,
} from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInUp,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Header,
  HeaderActions,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

type FeedEvent = {
  actor: string
  action: string
  detail: string
  tint?: string
  icon?: PhosphorIcon
  warning?: boolean
}

const FEED: FeedEvent[] = [
  {
    actor: "Jamie Rivera",
    action: "signed in",
    detail: "Book Pro · San Francisco",
    tint: "bg-indigo-500",
  },
  {
    actor: "System",
    action: "finished a backup",
    detail: "12.4 GB · 3 min",
    icon: CloudIcon,
  },
  {
    actor: "Morgan Lee",
    action: "updated a product",
    detail: "Phone Air · 240 in stock",
    tint: "bg-orange-500",
    icon: TagIcon,
  },
  {
    actor: "Riley Chen",
    action: "answered a ticket",
    detail: "#4821 Screen flickers",
    tint: "bg-green-500",
    icon: ChatCircleIcon,
  },
  {
    actor: "System",
    action: "raised a warning",
    detail: "API latency 820 ms",
    icon: WarningIcon,
    warning: true,
  },
  {
    actor: "Jordan Park",
    action: "approved an order",
    detail: "W1052 · $399",
    tint: "bg-teal-400",
    icon: HandbagIcon,
  },
]

type Entry = { id: number; time: string; event: FeedEvent }

const clock = (date: Date) => {
  const hours = date.getHours()
  const minutes = String(date.getMinutes()).padStart(2, "0")
  return `${hours % 12 || 12}:${minutes} ${hours < 12 ? "AM" : "PM"}`
}

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View className="flex-1 gap-2 px-4 pt-[3.25rem]">
      <Text role="heading" className="text-3xl font-bold">
        {label}
      </Text>
      <Text className="text-lg text-muted-foreground">
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function LiveDot() {
  const opacity = useSharedValue(1)

  React.useEffect(() => {
    opacity.value = withRepeat(
      withTiming(0.4, {
        duration: 1000,
        easing: Easing.bezier(0.4, 0, 0.6, 1),
      }),
      -1,
      true,
      undefined,
      ReduceMotion.System
    )
    return () => cancelAnimation(opacity)
  }, [opacity])

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

  return (
    <AnimatedView
      className="size-[0.4375rem] rounded-full bg-green-500"
      style={animatedStyle}
    />
  )
}

function EntryRow({ entry, first }: { entry: Entry; first: boolean }) {
  const { event } = entry
  return (
    <AnimatedView
      entering={FadeInUp.duration(700)
        .easing(Easing.bezier(0.32, 0.72, 0, 1))
        .reduceMotion(ReduceMotion.System)}
    >
      {first ? null : <View className="ms-14 h-px bg-border" />}
      <View className="min-h-11 flex-row items-center gap-3 px-4 py-2">
        {event.tint ? (
          <Avatar size="sm" className="size-7">
            <AvatarFallback className={event.tint}>
              {initials(event.actor)}
            </AvatarFallback>
          </Avatar>
        ) : (
          <View
            className={cn(
              "size-7 shrink-0 items-center justify-center rounded-[7px]",
              event.warning ? "bg-orange-500" : "bg-zinc-500"
            )}
          >
            {event.icon ? (
              <Icon
                as={event.icon}
                weight="bold"
                className="size-4 text-primary-foreground"
              />
            ) : null}
          </View>
        )}
        <View className="min-w-0 flex-1">
          <Text numberOfLines={1} className="text-sm">
            <Text className="text-sm font-semibold">{event.actor}</Text>
            {` ${event.action}`}
          </Text>
          <Text numberOfLines={1} className="text-xs text-muted-foreground">
            {event.detail}
          </Text>
        </View>
        <Text className="text-sm text-muted-foreground tabular-nums">
          {entry.time}
        </Text>
      </View>
    </AnimatedView>
  )
}

function LogLive({
  onRunningChange,
  onNavigate,
}: {
  onRunningChange?: (running: boolean) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("log")
  const [entries, setEntries] = React.useState<Entry[]>([])
  const [running, setRunning] = React.useState(true)
  const [count, setCount] = React.useState(0)
  const next = React.useRef(0)
  const scroll = useHeaderScroll()

  React.useEffect(() => {
    if (!running) return
    const push = () => {
      const id = next.current++
      const event = FEED[id % FEED.length]
      if (!event) return
      setCount((current) => current + 1)
      setEntries((current) =>
        [{ id, time: clock(new Date()), event }, ...current].slice(0, 7)
      )
    }
    if (next.current === 0) push()
    const timer = setInterval(push, 2200)
    return () => clearInterval(timer)
  }, [running])

  const toggle = () => {
    onRunningChange?.(!running)
    setRunning(!running)
  }

  const stats = [
    {
      label: "This session",
      value: `${count} ${count === 1 ? "event" : "events"}`,
    },
    { label: "Active users", value: "14" },
    { label: "Avg. response", value: "182 ms" },
  ]

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "log" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Live activity</HeaderTitle>
            <HeaderActions>
              <Button variant="secondary" size="sm" onPress={toggle}>
                <Icon
                  as={running ? PauseIcon : PlayIcon}
                  weight="fill"
                  className="size-3"
                />
                <Text>{running ? "Pause" : "Resume"}</Text>
              </Button>
            </HeaderActions>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle
              subtitle={running ? "Events appear as they happen" : "Paused"}
            >
              Live activity
            </HeaderLargeTitle>
            <View className="gap-5 px-4 pt-3 pb-8">
              <View className="flex-row overflow-hidden rounded-xl bg-muted">
                {stats.map((stat, index) => (
                  <View
                    key={stat.label}
                    accessible
                    className={cn(
                      "flex-1 px-3 py-3",
                      index > 0 && "border-s border-border"
                    )}
                  >
                    <Text
                      numberOfLines={1}
                      className="text-xs text-muted-foreground"
                    >
                      {stat.label}
                    </Text>
                    <Text
                      numberOfLines={1}
                      adjustsFontSizeToFit
                      className="text-xl font-semibold tabular-nums"
                    >
                      {stat.value}
                    </Text>
                  </View>
                ))}
              </View>
              <View className="-mb-2.5 flex-row items-center gap-2 px-1">
                <Text role="heading" className="flex-1 text-sm font-semibold">
                  Recent events
                </Text>
                {running ? (
                  <View className="flex-row items-center gap-1.5">
                    <LiveDot />
                    <Text className="text-xs text-muted-foreground">Live</Text>
                  </View>
                ) : null}
              </View>
              <View
                accessibilityLiveRegion="polite"
                className="overflow-hidden rounded-2xl bg-muted"
              >
                {entries.map((entry, index) => (
                  <EntryRow key={entry.id} entry={entry} first={index === 0} />
                ))}
              </View>
            </View>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Activity" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { LogLive }
```

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: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [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/log-live.tsx`:

```tsx title="stylesheet/components/blocks/log-live.tsx"
import {
  ChartBarIcon,
  ChatCircleIcon,
  CloudIcon,
  GearIcon,
  HandbagIcon,
  PauseIcon,
  type Icon as PhosphorIcon,
  PlayIcon,
  ShieldIcon,
  TagIcon,
  UsersIcon,
  WarningIcon,
} from "phosphor-react-native"
import * as React from "react"
import { StyleSheet, View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInUp,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Header,
  HeaderActions,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  radius,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

type Tone = keyof ThemeColors | keyof typeof palette

function tone(colors: ThemeColors, key: Tone) {
  return { ...colors, ...palette }[key]
}

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

type Tint = Tone

type FeedEvent = {
  actor: string
  action: string
  detail: string
  tint?: Tint
  icon?: PhosphorIcon
  warning?: boolean
}

const FEED: FeedEvent[] = [
  {
    actor: "Jamie Rivera",
    action: "signed in",
    detail: "Book Pro · San Francisco",
    tint: "indigo500",
  },
  {
    actor: "System",
    action: "finished a backup",
    detail: "12.4 GB · 3 min",
    icon: CloudIcon,
  },
  {
    actor: "Morgan Lee",
    action: "updated a product",
    detail: "Phone Air · 240 in stock",
    tint: "orange500",
    icon: TagIcon,
  },
  {
    actor: "Riley Chen",
    action: "answered a ticket",
    detail: "#4821 Screen flickers",
    tint: "green500",
    icon: ChatCircleIcon,
  },
  {
    actor: "System",
    action: "raised a warning",
    detail: "API latency 820 ms",
    icon: WarningIcon,
    warning: true,
  },
  {
    actor: "Jordan Park",
    action: "approved an order",
    detail: "W1052 · $399",
    tint: "teal400",
    icon: HandbagIcon,
  },
]

type Entry = { id: number; time: string; event: FeedEvent }

const styles = StyleSheet.create({
  other: { flex: 1, gap: 8, paddingHorizontal: 16, paddingTop: 52 },
  otherTitle: { ...fontSize["3xl"], fontWeight: "700" },
  dot: { width: 7, height: 7, borderRadius: radius.full },
  separator: { marginStart: 56, height: 1 },
  row: {
    minHeight: 44,
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    paddingHorizontal: 16,
    paddingVertical: 8,
  },
  avatar: { width: 28, height: 28 },
  tile: {
    width: 28,
    height: 28,
    flexShrink: 0,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: 7,
  },
  text: { minWidth: 0, flex: 1 },
  strong: { ...fontSize.sm, fontWeight: "600" },
  time: { ...fontSize.sm, fontVariant: ["tabular-nums"] },
  body: { gap: 20, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 32 },
  stats: {
    flexDirection: "row",
    overflow: "hidden",
    borderRadius: radius.xl,
  },
  stat: { flex: 1, paddingHorizontal: 12, paddingVertical: 12 },
  statDivider: { borderStartWidth: 1 },
  statValue: {
    ...fontSize.xl,
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
  heading: {
    marginBottom: -10,
    flexDirection: "row",
    alignItems: "center",
    gap: 8,
    paddingHorizontal: 4,
  },
  headingText: { ...fontSize.sm, flex: 1, fontWeight: "600" },
  live: { flexDirection: "row", alignItems: "center", gap: 6 },
  group: { overflow: "hidden", borderRadius: radius["2xl"] },
})

const clock = (date: Date) => {
  const hours = date.getHours()
  const minutes = String(date.getMinutes()).padStart(2, "0")
  return `${hours % 12 || 12}:${minutes} ${hours < 12 ? "AM" : "PM"}`
}

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const { colors } = useTheme()
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View style={styles.other}>
      <Text role="heading" style={styles.otherTitle}>
        {label}
      </Text>
      <Text style={[fontSize.lg, { color: colors.mutedForeground }]}>
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function LiveDot() {
  const opacity = useSharedValue(1)

  React.useEffect(() => {
    opacity.value = withRepeat(
      withTiming(0.4, {
        duration: 1000,
        easing: Easing.bezier(0.4, 0, 0.6, 1),
      }),
      -1,
      true,
      undefined,
      ReduceMotion.System
    )
    return () => cancelAnimation(opacity)
  }, [opacity])

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

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

function EntryRow({ entry, first }: { entry: Entry; first: boolean }) {
  const { colors } = useTheme()
  const { event } = entry
  return (
    <AnimatedView
      entering={FadeInUp.duration(700)
        .easing(Easing.bezier(0.32, 0.72, 0, 1))
        .reduceMotion(ReduceMotion.System)}
    >
      {first ? null : (
        <View style={[styles.separator, { backgroundColor: colors.border }]} />
      )}
      <View style={styles.row}>
        {event.tint ? (
          <Avatar size="sm" style={styles.avatar}>
            <AvatarFallback
              style={{ backgroundColor: tone(colors, event.tint) }}
            >
              {initials(event.actor)}
            </AvatarFallback>
          </Avatar>
        ) : (
          <View
            style={[
              styles.tile,
              {
                backgroundColor: event.warning
                  ? palette.orange500
                  : palette.zinc500,
              },
            ]}
          >
            {event.icon ? (
              <Icon
                as={event.icon}
                weight="bold"
                size={16}
                color={colors.primaryForeground}
              />
            ) : null}
          </View>
        )}
        <View style={styles.text}>
          <Text numberOfLines={1} style={fontSize.sm}>
            <Text style={styles.strong}>{event.actor}</Text>
            {` ${event.action}`}
          </Text>
          <Text
            numberOfLines={1}
            style={[fontSize.xs, { color: colors.mutedForeground }]}
          >
            {event.detail}
          </Text>
        </View>
        <Text style={[styles.time, { color: colors.mutedForeground }]}>
          {entry.time}
        </Text>
      </View>
    </AnimatedView>
  )
}

function LogLive({
  onRunningChange,
  onNavigate,
}: {
  onRunningChange?: (running: boolean) => void
  onNavigate?: (section: string) => void
}) {
  const { colors } = useTheme()
  const [section, setSection] = React.useState("log")
  const [entries, setEntries] = React.useState<Entry[]>([])
  const [running, setRunning] = React.useState(true)
  const [count, setCount] = React.useState(0)
  const next = React.useRef(0)
  const scroll = useHeaderScroll()

  React.useEffect(() => {
    if (!running) return
    const push = () => {
      const id = next.current++
      const event = FEED[id % FEED.length]
      if (!event) return
      setCount((current) => current + 1)
      setEntries((current) =>
        [{ id, time: clock(new Date()), event }, ...current].slice(0, 7)
      )
    }
    if (next.current === 0) push()
    const timer = setInterval(push, 2200)
    return () => clearInterval(timer)
  }, [running])

  const toggle = () => {
    onRunningChange?.(!running)
    setRunning(!running)
  }

  const stats = [
    {
      label: "This session",
      value: `${count} ${count === 1 ? "event" : "events"}`,
    },
    { label: "Active users", value: "14" },
    { label: "Avg. response", value: "182 ms" },
  ]

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "log" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Live activity</HeaderTitle>
            <HeaderActions>
              <Button variant="secondary" size="sm" onPress={toggle}>
                <Icon
                  as={running ? PauseIcon : PlayIcon}
                  weight="fill"
                  size={12}
                />
                <Text>{running ? "Pause" : "Resume"}</Text>
              </Button>
            </HeaderActions>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle
              subtitle={running ? "Events appear as they happen" : "Paused"}
            >
              Live activity
            </HeaderLargeTitle>
            <View style={styles.body}>
              <View style={[styles.stats, { backgroundColor: colors.muted }]}>
                {stats.map((stat, index) => (
                  <View
                    key={stat.label}
                    accessible
                    style={[
                      styles.stat,
                      index > 0 && [
                        styles.statDivider,
                        { borderColor: colors.border },
                      ],
                    ]}
                  >
                    <Text
                      numberOfLines={1}
                      style={[fontSize.xs, { color: colors.mutedForeground }]}
                    >
                      {stat.label}
                    </Text>
                    <Text
                      numberOfLines={1}
                      adjustsFontSizeToFit
                      style={styles.statValue}
                    >
                      {stat.value}
                    </Text>
                  </View>
                ))}
              </View>
              <View style={styles.heading}>
                <Text role="heading" style={styles.headingText}>
                  Recent events
                </Text>
                {running ? (
                  <View style={styles.live}>
                    <LiveDot />
                    <Text
                      style={[fontSize.xs, { color: colors.mutedForeground }]}
                    >
                      Live
                    </Text>
                  </View>
                ) : null}
              </View>
              <View
                accessibilityLiveRegion="polite"
                style={[styles.group, { backgroundColor: colors.muted }]}
              >
                {entries.map((entry, index) => (
                  <EntryRow key={entry.id} entry={entry} first={index === 0} />
                ))}
              </View>
            </View>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Activity" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { LogLive }
```

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/LogLive.swift`: shown above

- `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/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Empty.swift`: see [Empty](https://ui.suiss.app/docs/components/empty.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/Header.swift`: see [Header](https://ui.suiss.app/docs/components/header.md)

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

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

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

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

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

- `swift/Components/TabBar.swift`: see [Tab Bar](https://ui.suiss.app/docs/components/tab-bar.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/LogLive.kt`: shown above

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

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

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

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

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

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

- `kotlin/components/TabBar.kt`: see [Tab Bar](https://ui.suiss.app/docs/components/tab-bar.md)

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

## Usage

```tsx
import { LogLive } from "@/components/blocks/log-live"
```

```tsx
<LogLive />
```

Replace `FEED` with your events and swap the timer for your real-time source. Wire `onRunningChange` and `onNavigate` to your app.

