# Log Activity

An admin activity log grouped by day, with a type filter and expandable before-and-after details.

Log Activity 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-activity

**React**

**Example**

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

import {
  CaretRightIcon,
  ChartBarIcon,
  GearIcon,
  HandbagIcon,
  NotePencilIcon,
  ShieldIcon,
  TagIcon,
  UserIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
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 kinds = {
  Price: {
    icon: (
      <TagIcon className="size-4" />
    ),
    tint: "bg-orange-500",
  },
  Content: {
    icon: (
      <NotePencilIcon className="size-4" />
    ),
    tint: "bg-blue-500",
  },
  Security: {
    icon: (
      <ShieldIcon className="size-4" />
    ),
    tint: "bg-red-500",
  },
  Order: {
    icon: (
      <HandbagIcon className="size-4" />
    ),
    tint: "bg-green-500",
  },
  User: {
    icon: (
      <UserIcon className="size-4" />
    ),
    tint: "bg-purple-500",
  },
}

type Kind = keyof typeof kinds

const events: {
  day: string
  time: string
  actor: string
  action: string
  target: string
  kind: Kind
  diff?: [string, string]
}[] = [
  {
    day: "Today",
    time: "9:41 AM",
    actor: "Jamie Rivera",
    action: "updated",
    target: "Price: Phone Pro",
    kind: "Price",
    diff: ["$1,049", "$1,099"],
  },
  {
    day: "Today",
    time: "9:12 AM",
    actor: "Morgan Lee",
    action: "published",
    target: "Blog: October campaign",
    kind: "Content",
  },
  {
    day: "Today",
    time: "8:55 AM",
    actor: "System",
    action: "blocked",
    target: "5 failed sign-ins · 85.104.x.x",
    kind: "Security",
  },
  {
    day: "Yesterday",
    time: "6:20 PM",
    actor: "Riley Chen",
    action: "refunded",
    target: "Order W1044 · $999",
    kind: "Order",
  },
  {
    day: "Yesterday",
    time: "2:03 PM",
    actor: "Jamie Rivera",
    action: "changed a role",
    target: "Taylor Kim: Editor → Viewer",
    kind: "User",
    diff: ["Editor", "Viewer"],
  },
  {
    day: "Yesterday",
    time: "10:31 AM",
    actor: "Jordan Park",
    action: "created",
    target: "Product: MagCharge Wallet",
    kind: "Content",
  },
]

function EventRow({ event }: { event: (typeof events)[number] }) {
  const kind = kinds[event.kind]
  const body = (
    <>
      <span
        className={`flex size-7 shrink-0 items-center justify-center rounded-[7px] text-white ${kind.tint}`}
      >
        {kind.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.target}
        </span>
      </span>
      <span className="text-sm text-muted-foreground tabular-nums">
        {event.time}
      </span>
    </>
  )

  if (!event.diff) {
    return (
      <div className="flex min-h-11 items-center gap-3 py-2 ps-4 pe-9">
        {body}
      </div>
    )
  }

  return (
    <Collapsible>
      <CollapsibleTrigger className="group/event flex min-h-11 w-full items-center gap-3 py-2 ps-4 pe-4 text-start outline-none hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50 hover:text-accent-foreground">
        {body}
        <CaretRightIcon
          className="size-3 text-muted-foreground transition-[rotate] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] rtl:rotate-180 group-data-panel-open/event:rotate-90"
        />
      </CollapsibleTrigger>
      <CollapsibleContent className="h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-ending-style:h-0 data-starting-style:h-0 motion-reduce:transition-none">
        <dl className="flex flex-wrap items-center gap-x-4 gap-y-1 ps-14 pe-4 pb-3 text-sm">
          <div className="flex items-center gap-2">
            <dt className="text-xs text-muted-foreground">Before</dt>
            <dd className="text-muted-foreground line-through">
              {event.diff[0]}
            </dd>
          </div>
          <div className="flex items-center gap-2">
            <dt className="text-xs text-muted-foreground">After</dt>
            <dd className="font-semibold">{event.diff[1]}</dd>
          </div>
        </dl>
      </CollapsibleContent>
    </Collapsible>
  )
}

export function LogActivity() {
  const [filter, setFilter] = React.useState("All")
  const list = events.filter(
    (event) => filter === "All" || event.kind === filter
  )
  const days = [...new Set(list.map((event) => event.day))]

  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 flex-wrap items-start gap-3">
            <SidebarTrigger className="-ms-2 mt-0.5" />
            <h1 className="flex-1 text-3xl font-semibold tracking-tight whitespace-nowrap">
              Activity log
            </h1>
            <div className="flex items-center gap-2">
              <Select
                value={filter}
                onValueChange={(value) => setFilter(value as string)}
              >
                <SelectTrigger size="sm" aria-label="Type" className="h-7.5">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent align="end">
                  {["All", ...Object.keys(kinds)].map((item) => (
                    <SelectItem key={item} value={item}>
                      {item}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              <Button size="sm" variant="secondary">
                Export
              </Button>
            </div>
          </div>
          <div
            key={filter}
            className="flex flex-col gap-5 transition-[opacity,translate] duration-700 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none"
          >
            {days.map((day) => (
              <section key={day} className="flex flex-col gap-2">
                <h2 className="px-1 text-sm font-semibold">{day}</h2>
                <ul className="overflow-hidden rounded-2xl bg-muted">
                  {list
                    .filter((event) => event.day === day)
                    .map((event) => (
                      <li
                        key={`${event.time}-${event.actor}`}
                        className="relative not-first:before:absolute not-first:before:inset-e-0 not-first:before:start-14 not-first:before:top-0 not-first:before:h-px not-first:before:bg-border"
                      >
                        <EventRow event={event} />
                      </li>
                    ))}
                </ul>
              </section>
            ))}
          </div>
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

An audit log that groups events by day, each with an icon for its type, the person, the action, the target and a time. Events that changed a value expand to show a before-and-after comparison, and a type filter and Export button sit in the header. It sits in a `Sidebar` with admin navigation that opens as a sheet from the trigger on mobile. Use it for audit trails, change history or admin activity pages.

## Installation

### CLI

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

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-activity
```

### Manual

1. Install the dependencies:

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

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

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

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

import {
  CaretRightIcon,
  ChartBarIcon,
  GearIcon,
  HandbagIcon,
  NotePencilIcon,
  ShieldIcon,
  TagIcon,
  UserIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
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 kinds = {
  Price: {
    icon: (
      <TagIcon className="size-4" />
    ),
    tint: "bg-orange-500",
  },
  Content: {
    icon: (
      <NotePencilIcon className="size-4" />
    ),
    tint: "bg-blue-500",
  },
  Security: {
    icon: (
      <ShieldIcon className="size-4" />
    ),
    tint: "bg-red-500",
  },
  Order: {
    icon: (
      <HandbagIcon className="size-4" />
    ),
    tint: "bg-green-500",
  },
  User: {
    icon: (
      <UserIcon className="size-4" />
    ),
    tint: "bg-purple-500",
  },
}

type Kind = keyof typeof kinds

const events: {
  day: string
  time: string
  actor: string
  action: string
  target: string
  kind: Kind
  diff?: [string, string]
}[] = [
  {
    day: "Today",
    time: "9:41 AM",
    actor: "Jamie Rivera",
    action: "updated",
    target: "Price: Phone Pro",
    kind: "Price",
    diff: ["$1,049", "$1,099"],
  },
  {
    day: "Today",
    time: "9:12 AM",
    actor: "Morgan Lee",
    action: "published",
    target: "Blog: October campaign",
    kind: "Content",
  },
  {
    day: "Today",
    time: "8:55 AM",
    actor: "System",
    action: "blocked",
    target: "5 failed sign-ins · 85.104.x.x",
    kind: "Security",
  },
  {
    day: "Yesterday",
    time: "6:20 PM",
    actor: "Riley Chen",
    action: "refunded",
    target: "Order W1044 · $999",
    kind: "Order",
  },
  {
    day: "Yesterday",
    time: "2:03 PM",
    actor: "Jamie Rivera",
    action: "changed a role",
    target: "Taylor Kim: Editor → Viewer",
    kind: "User",
    diff: ["Editor", "Viewer"],
  },
  {
    day: "Yesterday",
    time: "10:31 AM",
    actor: "Jordan Park",
    action: "created",
    target: "Product: MagCharge Wallet",
    kind: "Content",
  },
]

function EventRow({ event }: { event: (typeof events)[number] }) {
  const kind = kinds[event.kind]
  const body = (
    <>
      <span
        className={`flex size-7 shrink-0 items-center justify-center rounded-[7px] text-white ${kind.tint}`}
      >
        {kind.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.target}
        </span>
      </span>
      <span className="text-sm text-muted-foreground tabular-nums">
        {event.time}
      </span>
    </>
  )

  if (!event.diff) {
    return (
      <div className="flex min-h-11 items-center gap-3 py-2 ps-4 pe-9">
        {body}
      </div>
    )
  }

  return (
    <Collapsible>
      <CollapsibleTrigger className="group/event flex min-h-11 w-full items-center gap-3 py-2 ps-4 pe-4 text-start outline-none hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50 hover:text-accent-foreground">
        {body}
        <CaretRightIcon
          className="size-3 text-muted-foreground transition-[rotate] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] rtl:rotate-180 group-data-panel-open/event:rotate-90"
        />
      </CollapsibleTrigger>
      <CollapsibleContent className="h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-ending-style:h-0 data-starting-style:h-0 motion-reduce:transition-none">
        <dl className="flex flex-wrap items-center gap-x-4 gap-y-1 ps-14 pe-4 pb-3 text-sm">
          <div className="flex items-center gap-2">
            <dt className="text-xs text-muted-foreground">Before</dt>
            <dd className="text-muted-foreground line-through">
              {event.diff[0]}
            </dd>
          </div>
          <div className="flex items-center gap-2">
            <dt className="text-xs text-muted-foreground">After</dt>
            <dd className="font-semibold">{event.diff[1]}</dd>
          </div>
        </dl>
      </CollapsibleContent>
    </Collapsible>
  )
}

export function LogActivity() {
  const [filter, setFilter] = React.useState("All")
  const list = events.filter(
    (event) => filter === "All" || event.kind === filter
  )
  const days = [...new Set(list.map((event) => event.day))]

  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 flex-wrap items-start gap-3">
            <SidebarTrigger className="-ms-2 mt-0.5" />
            <h1 className="flex-1 text-3xl font-semibold tracking-tight whitespace-nowrap">
              Activity log
            </h1>
            <div className="flex items-center gap-2">
              <Select
                value={filter}
                onValueChange={(value) => setFilter(value as string)}
              >
                <SelectTrigger size="sm" aria-label="Type" className="h-7.5">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent align="end">
                  {["All", ...Object.keys(kinds)].map((item) => (
                    <SelectItem key={item} value={item}>
                      {item}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              <Button size="sm" variant="secondary">
                Export
              </Button>
            </div>
          </div>
          <div
            key={filter}
            className="flex flex-col gap-5 transition-[opacity,translate] duration-700 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none"
          >
            {days.map((day) => (
              <section key={day} className="flex flex-col gap-2">
                <h2 className="px-1 text-sm font-semibold">{day}</h2>
                <ul className="overflow-hidden rounded-2xl bg-muted">
                  {list
                    .filter((event) => event.day === day)
                    .map((event) => (
                      <li
                        key={`${event.time}-${event.actor}`}
                        className="relative not-first:before:absolute not-first:before:inset-e-0 not-first:before:start-14 not-first:before:top-0 not-first:before:h-px not-first:before:bg-border"
                      >
                        <EventRow event={event} />
                      </li>
                    ))}
                </ul>
              </section>
            ))}
          </div>
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { LogActivity } from "@/components/blocks/log-activity"
```

```tsx
<LogActivity />
```

Replace `events` with entries from your audit log API; each has a day, time, actor, action, target, kind and an optional before-and-after diff. `kinds` sets each event type's icon and tint, and `nav` the shell navigation. Give the Export button a handler.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/log-activity.tsx"
import { cn } from "cn"
import {
  CaretRightIcon,
  ChartBarIcon,
  GearIcon,
  HandbagIcon,
  NotePencilIcon,
  type Icon as PhosphorIcon,
  ShieldIcon,
  TagIcon,
  UserIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { I18nManager, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"
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 },
]

const KINDS = {
  Price: { icon: TagIcon, tint: "bg-orange-500" },
  Content: { icon: NotePencilIcon, tint: "bg-blue-500" },
  Security: { icon: ShieldIcon, tint: "bg-red-500" },
  Order: { icon: HandbagIcon, tint: "bg-green-500" },
  User: { icon: UserIcon, tint: "bg-purple-500" },
}

type Kind = keyof typeof KINDS

const FILTERS = ["All", ...Object.keys(KINDS)]

type Event = {
  day: string
  time: string
  actor: string
  action: string
  target: string
  kind: Kind
  diff?: [string, string]
}

const EVENTS: Event[] = [
  {
    day: "Today",
    time: "9:41 AM",
    actor: "Jamie Rivera",
    action: "updated",
    target: "Price: Phone Pro",
    kind: "Price",
    diff: ["$1,049", "$1,099"],
  },
  {
    day: "Today",
    time: "9:12 AM",
    actor: "Morgan Lee",
    action: "published",
    target: "Blog: October campaign",
    kind: "Content",
  },
  {
    day: "Today",
    time: "8:55 AM",
    actor: "System",
    action: "blocked",
    target: "5 failed sign-ins · 85.104.x.x",
    kind: "Security",
  },
  {
    day: "Yesterday",
    time: "6:20 PM",
    actor: "Riley Chen",
    action: "refunded",
    target: "Order W1044 · $999",
    kind: "Order",
  },
  {
    day: "Yesterday",
    time: "2:03 PM",
    actor: "Jamie Rivera",
    action: "changed a role",
    target: "Taylor Kim: Editor → Viewer",
    kind: "User",
    diff: ["Editor", "Viewer"],
  },
  {
    day: "Yesterday",
    time: "10:31 AM",
    actor: "Jordan Park",
    action: "created",
    target: "Product: MagCharge Wallet",
    kind: "Content",
  },
]

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 EventBody({ event }: { event: Event }) {
  const kind = KINDS[event.kind]
  return (
    <>
      <View
        className={cn(
          "size-7 shrink-0 items-center justify-center rounded-[7px]",
          kind.tint
        )}
      >
        <Icon
          as={kind.icon}
          weight="bold"
          className="size-4 text-primary-foreground"
        />
      </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.target}
        </Text>
      </View>
      <Text className="text-sm text-muted-foreground tabular-nums">
        {event.time}
      </Text>
    </>
  )
}

function EventRow({ event }: { event: Event }) {
  const [open, setOpen] = React.useState(false)

  if (!event.diff) {
    return (
      <View className="min-h-11 flex-row items-center gap-3 py-2 ps-4 pe-9.5">
        <EventBody event={event} />
      </View>
    )
  }

  return (
    <Collapsible open={open} onOpenChange={setOpen}>
      <CollapsibleTrigger className="min-h-11 flex-row items-center gap-3 px-4 py-2 active:opacity-80">
        <EventBody event={event} />
        <Icon
          as={CaretRightIcon}
          weight="bold"
          className={cn(
            "size-3 text-muted-foreground",
            open ? "rotate-90" : I18nManager.isRTL && "rotate-180"
          )}
        />
      </CollapsibleTrigger>
      <CollapsibleContent>
        <AnimatedView
          entering={FadeIn.duration(300).reduceMotion(ReduceMotion.System)}
          className="flex-row flex-wrap items-center gap-x-4 gap-y-1 ps-14 pe-4 pb-3"
        >
          <View className="flex-row items-center gap-2">
            <Text className="text-xs text-muted-foreground">Before</Text>
            <Text className="text-sm text-muted-foreground line-through">
              {event.diff[0]}
            </Text>
          </View>
          <View className="flex-row items-center gap-2">
            <Text className="text-xs text-muted-foreground">After</Text>
            <Text className="text-sm font-semibold">{event.diff[1]}</Text>
          </View>
        </AnimatedView>
      </CollapsibleContent>
    </Collapsible>
  )
}

function LogActivity({
  onFilterChange,
  onExport,
  onNavigate,
}: {
  onFilterChange?: (filter: string) => void
  onExport?: (filter: string) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("log")
  const [filter, setFilter] = React.useState("All")
  const scroll = useHeaderScroll()
  const list = EVENTS.filter(
    (event) => filter === "All" || event.kind === filter
  )
  const days = [...new Set(list.map((event) => event.day))]

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "log" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Activity log</HeaderTitle>
            <HeaderActions>
              <Button
                variant="secondary"
                size="sm"
                onPress={() => onExport?.(filter)}
              >
                <Text>Export</Text>
              </Button>
            </HeaderActions>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle>Activity log</HeaderLargeTitle>
            <ChipGroup
              scrollable
              value={[filter]}
              onValueChange={(value) => {
                if (!value[0]) return
                setFilter(value[0])
                onFilterChange?.(value[0])
              }}
              accessibilityLabel="Type"
              className="px-4 pt-1 pb-2"
            >
              {FILTERS.map((item) => (
                <Chip key={item} value={item}>
                  {item}
                </Chip>
              ))}
            </ChipGroup>
            <AnimatedView
              key={filter}
              entering={FadeInDown.duration(700).reduceMotion(
                ReduceMotion.System
              )}
              className="gap-5 px-4 pt-3 pb-8"
            >
              {days.map((day) => (
                <View key={day} className="gap-2">
                  <Text role="heading" className="px-1 text-sm font-semibold">
                    {day}
                  </Text>
                  <View className="overflow-hidden rounded-2xl bg-muted">
                    {list
                      .filter((event) => event.day === day)
                      .map((event, index) => (
                        <View key={`${event.time}-${event.actor}`}>
                          {index > 0 ? (
                            <View className="ms-14 h-px bg-border" />
                          ) : null}
                          <EventRow event={event} />
                        </View>
                      ))}
                  </View>
                </View>
              ))}
            </AnimatedView>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Activity" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { LogActivity }
```

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

public struct LogActivity: View {
  struct Kind {
    let value: String
    let icon: Image
    let tint: Color
  }

  struct Event: Identifiable {
    let day: String
    let time: String
    let actor: String
    let action: String
    let target: String
    let kind: String
    var diff: (String, String)?

    var id: String { "\(day)-\(time)-\(actor)" }
  }

  static var kinds: [Kind] {
    [
      Kind(value: "Price", icon: Ph.tag.bold, tint: Palette.orange500),
      Kind(value: "Content", icon: Ph.notePencil.bold, tint: Palette.blue500),
      Kind(value: "Security", icon: Ph.shield.bold, tint: Palette.red500),
      Kind(value: "Order", icon: Ph.handbag.bold, tint: Palette.green500),
      Kind(value: "User", icon: Ph.user.bold, tint: Palette.purple500),
    ]
  }

  static var events: [Event] {
    [
      Event(day: "Today", time: "9:41 AM", actor: "Jamie Rivera", action: "updated", target: "Price: Phone Pro", kind: "Price", diff: ("$1,049", "$1,099")),
      Event(day: "Today", time: "9:12 AM", actor: "Morgan Lee", action: "published", target: "Blog: October campaign", kind: "Content"),
      Event(day: "Today", time: "8:55 AM", actor: "System", action: "blocked", target: "5 failed sign-ins · 85.104.x.x", kind: "Security"),
      Event(day: "Yesterday", time: "6:20 PM", actor: "Riley Chen", action: "refunded", target: "Order W1044 · $999", kind: "Order"),
      Event(day: "Yesterday", time: "2:03 PM", actor: "Jamie Rivera", action: "changed a role", target: "Taylor Kim: Editor → Viewer", kind: "User", diff: ("Editor", "Viewer")),
      Event(day: "Yesterday", time: "10:31 AM", actor: "Jordan Park", action: "created", target: "Product: MagCharge Wallet", kind: "Content"),
    ]
  }

  static var filters: [String] { ["All"] + kinds.map(\.value) }

  static func filtered(_ filter: String) -> [Event] {
    events.filter { filter == "All" || $0.kind == filter }
  }

  private var onNavigate: ((String) -> Void)?
  private var onFilterChange: ((String) -> Void)?
  private var onExport: ((String) -> Void)?
  private let expanded: Set<String>
  @State private var filter: String?
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onFilterChange: ((_ filter: String) -> Void)? = nil,
    onExport: ((_ filter: String) -> Void)? = nil,
    onNavigate: ((_ section: String) -> Void)? = nil
  ) {
    self.init(filter: "All")
    self.onFilterChange = onFilterChange
    self.onExport = onExport
    self.onNavigate = onNavigate
  }

  init(filter: String = "All", expanded: Set<String> = []) {
    _filter = State(initialValue: filter)
    self.expanded = expanded
  }

  private var filterSelection: Binding<String?> {
    Binding(
      get: { filter },
      set: { value in
        let next = value ?? "All"
        guard next != filter else { return }
        withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.5)) {
          filter = next
        }
        onFilterChange?(next)
      }
    )
  }

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

  @ViewBuilder private var page: some View {
    let list = Self.filtered(filter ?? "All")
    let days = list.reduce(into: [String]()) { result, event in
      if !result.contains(event.day) { result.append(event.day) }
    }
    Screen {
      HeaderScrollView {
        HeaderLargeTitle("Activity log")
        VStack(alignment: .leading, spacing: 20) {
          ChipGroup(selection: filterSelection, scrollable: true) {
            ForEach(Self.filters, id: \.self) { item in
              Chip(LocalizedStringKey(item), value: item)
            }
          }
          .contentMargins(.horizontal, 16, for: .scrollContent)
          .accessibilityLabel(Text("Type"))
          VStack(alignment: .leading, spacing: 20) {
            ForEach(days, id: \.self) { day in
              VStack(alignment: .leading, spacing: 8) {
                Text(LocalizedStringKey(day))
                  .scaledFont(.sm, weight: .semibold)
                  .padding(.horizontal, 4)
                  .accessibilityAddTraits(.isHeader)
                let rows = list.filter { $0.day == day }
                VStack(spacing: 0) {
                  ForEach(Array(rows.enumerated()), id: \.element.id) { index, event in
                    LogActivityRow(event: event, expanded: expanded.contains(event.id))
                      .overlay(alignment: .top) {
                        if index > 0 {
                          EsuissColor.border.frame(height: 1).padding(.leading, 56)
                        }
                      }
                  }
                }
                .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
                .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
              }
            }
          }
          .id(filter)
          .transition(.opacity.combined(with: .offset(y: 6)))
          .padding(.horizontal, 16)
        }
        .padding(.top, 4)
        .padding(.bottom, 24)
      } header: {
        Header {
          HeaderTitle("Activity log")
        } trailing: {
          Button("Export") {
            onExport?(filter ?? "All")
          }
          .buttonStyle(.variant(.secondary, size: .sm))
        }
      }
    }
  }
}

private struct LogActivityRow: View {
  let event: LogActivity.Event

  @State private var open: Bool
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  init(event: LogActivity.Event, expanded: Bool) {
    self.event = event
    _open = State(initialValue: expanded)
  }

  var body: some View {
    if let diff = event.diff {
      Collapsible(isExpanded: $open) {
        CollapsibleTrigger {
          row(caret: true)
        }
        .buttonStyle(LogActivityRowStyle())
        CollapsibleContent {
          HStack(spacing: 16) {
            HStack(spacing: 8) {
              Text("Before")
                .scaledFont(.xs)
                .foregroundStyle(EsuissColor.mutedForeground)
              Text(verbatim: diff.0)
                .scaledFont(.sm)
                .strikethrough()
                .foregroundStyle(EsuissColor.mutedForeground)
            }
            HStack(spacing: 8) {
              Text("After")
                .scaledFont(.xs)
                .foregroundStyle(EsuissColor.mutedForeground)
              Text(verbatim: diff.1)
                .scaledFont(.sm, weight: .semibold)
            }
          }
          .accessibilityElement(children: .combine)
          .padding(.leading, 56)
          .padding(.trailing, 16)
          .padding(.bottom, 12)
          .frame(maxWidth: .infinity, alignment: .leading)
        }
      }
    } else {
      row(caret: false)
    }
  }

  private func row(caret: Bool) -> some View {
    let kind = LogActivity.kinds.first { $0.value == event.kind } ?? LogActivity.kinds[0]
    return HStack(spacing: 12) {
      kind.icon
        .frame(width: 16, height: 16)
        .foregroundStyle(EsuissColor.primaryForeground)
        .frame(width: 28, height: 28)
        .background(kind.tint, 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.target)
          .scaledFont(.xs)
          .foregroundStyle(EsuissColor.mutedForeground)
          .lineLimit(1)
      }
      .frame(maxWidth: .infinity, alignment: .leading)
      Text(LocalizedStringKey(event.time))
        .scaledFont(.sm)
        .monospacedDigit()
        .foregroundStyle(EsuissColor.mutedForeground)
      if caret {
        Ph.caretRight.bold
          .frame(width: 12, height: 12)
          .foregroundStyle(EsuissColor.mutedForeground)
          .flipsForRightToLeftLayoutDirection(true)
          .rotationEffect(.degrees(open ? 90 : 0))
          .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.3), value: open)
      }
    }
    .foregroundStyle(EsuissColor.foreground)
    .padding(.leading, 16)
    .padding(.trailing, caret ? 16 : 36)
    .padding(.vertical, 8)
    .frame(minHeight: 44)
    .contentShape(Rectangle())
    .accessibilityElement(children: .combine)
  }
}

private struct LogActivityRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}

private struct LogActivityShell<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/LogActivity.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
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.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.rotate
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.heading
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.TextDecoration
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.CaretRight
import com.adamglin.phosphoricons.bold.Handbag
import com.adamglin.phosphoricons.bold.NotePencil
import com.adamglin.phosphoricons.bold.Shield
import com.adamglin.phosphoricons.bold.Tag
import com.adamglin.phosphoricons.bold.User
import com.adamglin.phosphoricons.fill.ChartBar as FillChartBar
import com.adamglin.phosphoricons.fill.Gear as FillGear
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.Chip
import com.esuiss.ui.components.ChipGroup
import com.esuiss.ui.components.Collapsible
import com.esuiss.ui.components.CollapsibleContent
import com.esuiss.ui.components.CollapsibleTrigger
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.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.Typography

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

private class ActivityKind(val value: String, val icon: ImageVector, val tint: (Colors) -> Color)

private val ActivityKinds = listOf(
    ActivityKind("Price", PhosphorIcons.Bold.Tag) { Palette.orange500 },
    ActivityKind("Content", PhosphorIcons.Bold.NotePencil) { Palette.blue500 },
    ActivityKind("Security", PhosphorIcons.Bold.Shield) { Palette.red500 },
    ActivityKind("Order", PhosphorIcons.Bold.Handbag) { Palette.green500 },
    ActivityKind("User", PhosphorIcons.Bold.User) { Palette.purple500 },
)

private class ActivityEvent(
    val day: String,
    val time: String,
    val actor: String,
    val action: String,
    val target: String,
    val kind: String,
    val diff: Pair<String, String>? = null,
)

private val ActivityEvents = listOf(
    ActivityEvent("Today", "9:41 AM", "Jamie Rivera", "updated", "Price: Phone Pro", "Price", "\$1,049" to "\$1,099"),
    ActivityEvent("Today", "9:12 AM", "Morgan Lee", "published", "Blog: October campaign", "Content"),
    ActivityEvent("Today", "8:55 AM", "System", "blocked", "5 failed sign-ins · 85.104.x.x", "Security"),
    ActivityEvent("Yesterday", "6:20 PM", "Riley Chen", "refunded", "Order W1044 · \$999", "Order"),
    ActivityEvent("Yesterday", "2:03 PM", "Jamie Rivera", "changed a role", "Taylor Kim: Editor → Viewer", "User", "Editor" to "Viewer"),
    ActivityEvent("Yesterday", "10:31 AM", "Jordan Park", "created", "Product: MagCharge Wallet", "Content"),
)

@Composable
fun LogActivity(
    modifier: Modifier = Modifier,
    onFilterChange: (filter: String) -> Unit = {},
    onExport: (filter: String) -> Unit = {},
    onNavigate: (section: String) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    val header = rememberHeaderState()
    var filter by rememberSaveable { mutableStateOf("All") }
    val list = ActivityEvents.filter { filter == "All" || it.kind == filter }
    val days = list.map { it.day }.distinct()
    Screen(modifier, edges = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right)) {
        AdminTabs("log", onNavigate) {
            Column(
                Modifier
                    .fillMaxSize()
                    .nestedScroll(header.nestedScrollConnection),
            ) {
                Header(state = header) {
                    HeaderTitle("Activity log")
                    HeaderLargeTitle("Activity log")
                    HeaderActions {
                        Button(text = "Export", onClick = { onExport(filter) }, size = ButtonSize.Sm, variant = ButtonVariant.Secondary)
                    }
                }
                Column(
                    Modifier
                        .weight(1f)
                        .fillMaxWidth()
                        .verticalScroll(rememberScrollState())
                        .padding(bottom = 32.dp),
                    verticalArrangement = Arrangement.spacedBy(20.dp),
                ) {
                    ChipGroup(
                        value = filter,
                        onValueChange = { value ->
                            if (value != null && value != filter) {
                                onFilterChange(value)
                                filter = value
                            }
                        },
                        modifier = Modifier.padding(top = 8.dp),
                        scrollable = true,
                        contentPadding = PaddingValues(horizontal = 16.dp),
                    ) {
                        Chip("All", "All")
                        ActivityKinds.forEach { Chip(it.value, it.value, icon = it.icon) }
                    }
                    key(filter) {
                        ActivityAppear {
                            Column(
                                modifier = Modifier.padding(horizontal = 16.dp),
                                verticalArrangement = Arrangement.spacedBy(20.dp),
                            ) {
                                days.forEach { day ->
                                    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                                        BasicText(
                                            day,
                                            modifier = Modifier
                                                .padding(horizontal = 4.dp)
                                                .semantics { heading() },
                                            style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                                        )
                                        ItemGroup(variant = ItemGroupVariant.Inset) {
                                            list.filter { it.day == day }.forEachIndexed { index, event ->
                                                if (index > 0) ItemSeparator(Modifier.padding(start = 38.dp))
                                                ActivityRow(event)
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun ActivityAppear(content: @Composable () -> Unit) {
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(700, easing = ActivityRevealEasing)) }
    Box(
        Modifier.graphicsLayer {
            alpha = appear.value
            translationY = (1f - appear.value) * 6.dp.toPx()
        },
    ) {
        content()
    }
}

@Composable
private fun ActivityRow(event: ActivityEvent) {
    val diff = event.diff
    if (diff == null) {
        ActivityItem(event, expanded = null)
        return
    }
    var expanded by rememberSaveable { mutableStateOf(false) }
    Collapsible(expanded = expanded, onExpandedChange = { expanded = it }) {
        CollapsibleTrigger { ActivityItem(event, expanded = expanded) }
        CollapsibleContent { ActivityDiff(diff) }
    }
}

@Composable
private fun ActivityItem(event: ActivityEvent, expanded: Boolean?) {
    val colors = EsuissTheme.colors
    val kind = ActivityKinds.first { it.value == event.kind }
    val rotation by animateFloatAsState(if (expanded == true) 90f else 0f, tween(300, easing = ActivityRevealEasing), label = "caret")
    Item(size = ItemSize.Sm, role = null) {
        ItemMedia {
            Box(
                Modifier
                    .size(28.dp)
                    .background(kind.tint(colors), RoundedCornerShape(7.dp)),
                contentAlignment = Alignment.Center,
            ) {
                Icon(kind.icon, 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.target,
                style = Typography.xs.copy(color = colors.mutedForeground),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
        ItemActions {
            BasicText(event.time, style = Typography.sm.copy(color = colors.mutedForeground, fontFeatureSettings = "tnum"))
            Box(Modifier.size(12.dp), contentAlignment = Alignment.Center) {
                if (expanded != null) {
                    Icon(
                        PhosphorIcons.Bold.CaretRight,
                        contentDescription = null,
                        modifier = Modifier.rotate(rotation),
                        size = 12.dp,
                        tint = colors.mutedForeground,
                    )
                }
            }
        }
    }
}

@Composable
private fun ActivityDiff(diff: Pair<String, String>) {
    val colors = EsuissTheme.colors
    Row(
        modifier = Modifier.padding(start = 56.dp, end = 16.dp, bottom = 12.dp),
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
            BasicText("Before", style = Typography.xs.copy(color = colors.mutedForeground))
            BasicText(diff.first, style = Typography.sm.copy(color = colors.mutedForeground, textDecoration = TextDecoration.LineThrough))
        }
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
            BasicText("After", style = Typography.xs.copy(color = colors.mutedForeground))
            BasicText(diff.second, style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
        }
    }
}

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` and large title, a scrollable `ChipGroup` to filter by event type (Price, Content, Security, Order, User), and events grouped under day headings. Events that carry a change expand with `Collapsible` to show the before and after values. A `TabBar` at the bottom switches between Users, Roles, Activity and Settings; only Activity has content in the sample.

## Installation

**React Native**

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

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-activity
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Collapsible](https://ui.suiss.app/docs/components/collapsible), [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: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Collapsible](https://ui.suiss.app/docs/components/collapsible), [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-activity.tsx`:

- `uniwind/components/blocks/log-activity.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: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Collapsible](https://ui.suiss.app/docs/components/collapsible), [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-activity.tsx`:

```tsx title="nativewind/components/blocks/log-activity.tsx"
import { cn } from "cn"
import {
  CaretRightIcon,
  ChartBarIcon,
  GearIcon,
  HandbagIcon,
  NotePencilIcon,
  type Icon as PhosphorIcon,
  ShieldIcon,
  TagIcon,
  UserIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { I18nManager, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"
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 },
]

const KINDS = {
  Price: { icon: TagIcon, tint: "bg-orange-500" },
  Content: { icon: NotePencilIcon, tint: "bg-blue-500" },
  Security: { icon: ShieldIcon, tint: "bg-red-500" },
  Order: { icon: HandbagIcon, tint: "bg-green-500" },
  User: { icon: UserIcon, tint: "bg-purple-500" },
}

type Kind = keyof typeof KINDS

const FILTERS = ["All", ...Object.keys(KINDS)]

type Event = {
  day: string
  time: string
  actor: string
  action: string
  target: string
  kind: Kind
  diff?: [string, string]
}

const EVENTS: Event[] = [
  {
    day: "Today",
    time: "9:41 AM",
    actor: "Jamie Rivera",
    action: "updated",
    target: "Price: Phone Pro",
    kind: "Price",
    diff: ["$1,049", "$1,099"],
  },
  {
    day: "Today",
    time: "9:12 AM",
    actor: "Morgan Lee",
    action: "published",
    target: "Blog: October campaign",
    kind: "Content",
  },
  {
    day: "Today",
    time: "8:55 AM",
    actor: "System",
    action: "blocked",
    target: "5 failed sign-ins · 85.104.x.x",
    kind: "Security",
  },
  {
    day: "Yesterday",
    time: "6:20 PM",
    actor: "Riley Chen",
    action: "refunded",
    target: "Order W1044 · $999",
    kind: "Order",
  },
  {
    day: "Yesterday",
    time: "2:03 PM",
    actor: "Jamie Rivera",
    action: "changed a role",
    target: "Taylor Kim: Editor → Viewer",
    kind: "User",
    diff: ["Editor", "Viewer"],
  },
  {
    day: "Yesterday",
    time: "10:31 AM",
    actor: "Jordan Park",
    action: "created",
    target: "Product: MagCharge Wallet",
    kind: "Content",
  },
]

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 EventBody({ event }: { event: Event }) {
  const kind = KINDS[event.kind]
  return (
    <>
      <View
        className={cn(
          "size-7 shrink-0 items-center justify-center rounded-[7px]",
          kind.tint
        )}
      >
        <Icon
          as={kind.icon}
          weight="bold"
          className="size-4 text-primary-foreground"
        />
      </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.target}
        </Text>
      </View>
      <Text className="text-sm text-muted-foreground tabular-nums">
        {event.time}
      </Text>
    </>
  )
}

function EventRow({ event }: { event: Event }) {
  const [open, setOpen] = React.useState(false)

  if (!event.diff) {
    return (
      <View className="min-h-11 flex-row items-center gap-3 py-2 ps-4 pe-[2.375rem]">
        <EventBody event={event} />
      </View>
    )
  }

  return (
    <Collapsible open={open} onOpenChange={setOpen}>
      <CollapsibleTrigger className="min-h-11 flex-row items-center gap-3 px-4 py-2 active:opacity-80">
        <EventBody event={event} />
        <Icon
          as={CaretRightIcon}
          weight="bold"
          className={cn(
            "size-3 text-muted-foreground",
            open ? "rotate-90" : I18nManager.isRTL && "rotate-180"
          )}
        />
      </CollapsibleTrigger>
      <CollapsibleContent>
        <AnimatedView
          entering={FadeIn.duration(300).reduceMotion(ReduceMotion.System)}
          className="flex-row flex-wrap items-center gap-x-4 gap-y-1 ps-14 pe-4 pb-3"
        >
          <View className="flex-row items-center gap-2">
            <Text className="text-xs text-muted-foreground">Before</Text>
            <Text className="text-sm text-muted-foreground line-through">
              {event.diff[0]}
            </Text>
          </View>
          <View className="flex-row items-center gap-2">
            <Text className="text-xs text-muted-foreground">After</Text>
            <Text className="text-sm font-semibold">{event.diff[1]}</Text>
          </View>
        </AnimatedView>
      </CollapsibleContent>
    </Collapsible>
  )
}

function LogActivity({
  onFilterChange,
  onExport,
  onNavigate,
}: {
  onFilterChange?: (filter: string) => void
  onExport?: (filter: string) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("log")
  const [filter, setFilter] = React.useState("All")
  const scroll = useHeaderScroll()
  const list = EVENTS.filter(
    (event) => filter === "All" || event.kind === filter
  )
  const days = [...new Set(list.map((event) => event.day))]

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "log" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Activity log</HeaderTitle>
            <HeaderActions>
              <Button
                variant="secondary"
                size="sm"
                onPress={() => onExport?.(filter)}
              >
                <Text>Export</Text>
              </Button>
            </HeaderActions>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle>Activity log</HeaderLargeTitle>
            <ChipGroup
              scrollable
              value={[filter]}
              onValueChange={(value) => {
                if (!value[0]) return
                setFilter(value[0])
                onFilterChange?.(value[0])
              }}
              accessibilityLabel="Type"
              className="px-4 pt-1 pb-2"
            >
              {FILTERS.map((item) => (
                <Chip key={item} value={item}>
                  {item}
                </Chip>
              ))}
            </ChipGroup>
            <AnimatedView
              key={filter}
              entering={FadeInDown.duration(700).reduceMotion(
                ReduceMotion.System
              )}
              className="gap-5 px-4 pt-3 pb-8"
            >
              {days.map((day) => (
                <View key={day} className="gap-2">
                  <Text role="heading" className="px-1 text-sm font-semibold">
                    {day}
                  </Text>
                  <View className="overflow-hidden rounded-2xl bg-muted">
                    {list
                      .filter((event) => event.day === day)
                      .map((event, index) => (
                        <View key={`${event.time}-${event.actor}`}>
                          {index > 0 ? (
                            <View className="ms-14 h-px bg-border" />
                          ) : null}
                          <EventRow event={event} />
                        </View>
                      ))}
                  </View>
                </View>
              ))}
            </AnimatedView>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Activity" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { LogActivity }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Collapsible](https://ui.suiss.app/docs/components/collapsible), [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-activity.tsx`:

```tsx title="stylesheet/components/blocks/log-activity.tsx"
import {
  CaretRightIcon,
  ChartBarIcon,
  GearIcon,
  HandbagIcon,
  NotePencilIcon,
  type Icon as PhosphorIcon,
  ShieldIcon,
  TagIcon,
  UserIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { I18nManager, Pressable, StyleSheet, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"
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

const KINDS = {
  Price: { icon: TagIcon, tint: "orange500" },
  Content: { icon: NotePencilIcon, tint: "blue500" },
  Security: { icon: ShieldIcon, tint: "red500" },
  Order: { icon: HandbagIcon, tint: "green500" },
  User: { icon: UserIcon, tint: "purple500" },
} satisfies Record<string, { icon: PhosphorIcon; tint: Tint }>

type Kind = keyof typeof KINDS

const FILTERS = ["All", ...Object.keys(KINDS)]

type Event = {
  day: string
  time: string
  actor: string
  action: string
  target: string
  kind: Kind
  diff?: [string, string]
}

const EVENTS: Event[] = [
  {
    day: "Today",
    time: "9:41 AM",
    actor: "Jamie Rivera",
    action: "updated",
    target: "Price: Phone Pro",
    kind: "Price",
    diff: ["$1,049", "$1,099"],
  },
  {
    day: "Today",
    time: "9:12 AM",
    actor: "Morgan Lee",
    action: "published",
    target: "Blog: October campaign",
    kind: "Content",
  },
  {
    day: "Today",
    time: "8:55 AM",
    actor: "System",
    action: "blocked",
    target: "5 failed sign-ins · 85.104.x.x",
    kind: "Security",
  },
  {
    day: "Yesterday",
    time: "6:20 PM",
    actor: "Riley Chen",
    action: "refunded",
    target: "Order W1044 · $999",
    kind: "Order",
  },
  {
    day: "Yesterday",
    time: "2:03 PM",
    actor: "Jamie Rivera",
    action: "changed a role",
    target: "Taylor Kim: Editor → Viewer",
    kind: "User",
    diff: ["Editor", "Viewer"],
  },
  {
    day: "Yesterday",
    time: "10:31 AM",
    actor: "Jordan Park",
    action: "created",
    target: "Product: MagCharge Wallet",
    kind: "Content",
  },
]

const styles = StyleSheet.create({
  other: { flex: 1, gap: 8, paddingHorizontal: 16, paddingTop: 52 },
  otherTitle: { ...fontSize["3xl"], fontWeight: "700" },
  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"] },
  row: {
    minHeight: 44,
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    paddingVertical: 8,
    paddingStart: 16,
    paddingEnd: 38,
  },
  trigger: {
    minHeight: 44,
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    paddingHorizontal: 16,
    paddingVertical: 8,
  },
  diff: {
    flexDirection: "row",
    flexWrap: "wrap",
    alignItems: "center",
    columnGap: 16,
    rowGap: 4,
    paddingStart: 56,
    paddingEnd: 16,
    paddingBottom: 12,
  },
  diffItem: { flexDirection: "row", alignItems: "center", gap: 8 },
  chips: { paddingHorizontal: 16, paddingTop: 4, paddingBottom: 8 },
  body: { gap: 20, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 32 },
  day: { gap: 8 },
  dayTitle: { ...fontSize.sm, paddingHorizontal: 4, fontWeight: "600" },
  group: { overflow: "hidden", borderRadius: radius["2xl"] },
  separator: { marginStart: 56, height: 1 },
})

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 caretStyle(open: boolean) {
  if (open) return { transform: [{ rotate: "90deg" }] }
  if (I18nManager.isRTL) return { transform: [{ rotate: "180deg" }] }
  return
}

function EventBody({ event }: { event: Event }) {
  const { colors } = useTheme()
  const kind = KINDS[event.kind]
  return (
    <>
      <View style={[styles.tile, { backgroundColor: tone(colors, kind.tint) }]}>
        <Icon
          as={kind.icon}
          weight="bold"
          size={16}
          color={colors.primaryForeground}
        />
      </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.target}
        </Text>
      </View>
      <Text style={[styles.time, { color: colors.mutedForeground }]}>
        {event.time}
      </Text>
    </>
  )
}

function EventRow({ event }: { event: Event }) {
  const { colors } = useTheme()
  const [open, setOpen] = React.useState(false)

  if (!event.diff) {
    return (
      <View style={styles.row}>
        <EventBody event={event} />
      </View>
    )
  }

  return (
    <Collapsible open={open} onOpenChange={setOpen}>
      <CollapsibleTrigger
        style={styles.trigger}
        render={(renderProps) => {
          const { style: baseStyle, ...rest } = renderProps
          return (
            <Pressable
              {...rest}
              style={({ pressed }) => [baseStyle, pressed && { opacity: 0.8 }]}
            />
          )
        }}
      >
        <EventBody event={event} />
        <Icon
          as={CaretRightIcon}
          weight="bold"
          size={12}
          color={colors.mutedForeground}
          style={caretStyle(open)}
        />
      </CollapsibleTrigger>
      <CollapsibleContent>
        <AnimatedView
          entering={FadeIn.duration(300).reduceMotion(ReduceMotion.System)}
          style={styles.diff}
        >
          <View style={styles.diffItem}>
            <Text style={[fontSize.xs, { color: colors.mutedForeground }]}>
              Before
            </Text>
            <Text
              style={[
                fontSize.sm,
                {
                  color: colors.mutedForeground,
                  textDecorationLine: "line-through",
                },
              ]}
            >
              {event.diff[0]}
            </Text>
          </View>
          <View style={styles.diffItem}>
            <Text style={[fontSize.xs, { color: colors.mutedForeground }]}>
              After
            </Text>
            <Text style={styles.strong}>{event.diff[1]}</Text>
          </View>
        </AnimatedView>
      </CollapsibleContent>
    </Collapsible>
  )
}

function LogActivity({
  onFilterChange,
  onExport,
  onNavigate,
}: {
  onFilterChange?: (filter: string) => void
  onExport?: (filter: string) => void
  onNavigate?: (section: string) => void
}) {
  const { colors } = useTheme()
  const [section, setSection] = React.useState("log")
  const [filter, setFilter] = React.useState("All")
  const scroll = useHeaderScroll()
  const list = EVENTS.filter(
    (event) => filter === "All" || event.kind === filter
  )
  const days = [...new Set(list.map((event) => event.day))]

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "log" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Activity log</HeaderTitle>
            <HeaderActions>
              <Button
                variant="secondary"
                size="sm"
                onPress={() => onExport?.(filter)}
              >
                <Text>Export</Text>
              </Button>
            </HeaderActions>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle>Activity log</HeaderLargeTitle>
            <ChipGroup
              scrollable
              value={[filter]}
              onValueChange={(value) => {
                if (!value[0]) return
                setFilter(value[0])
                onFilterChange?.(value[0])
              }}
              accessibilityLabel="Type"
              style={styles.chips}
            >
              {FILTERS.map((item) => (
                <Chip key={item} value={item}>
                  {item}
                </Chip>
              ))}
            </ChipGroup>
            <AnimatedView
              key={filter}
              entering={FadeInDown.duration(700).reduceMotion(
                ReduceMotion.System
              )}
              style={styles.body}
            >
              {days.map((day) => (
                <View key={day} style={styles.day}>
                  <Text role="heading" style={styles.dayTitle}>
                    {day}
                  </Text>
                  <View
                    style={[styles.group, { backgroundColor: colors.muted }]}
                  >
                    {list
                      .filter((event) => event.day === day)
                      .map((event, index) => (
                        <View key={`${event.time}-${event.actor}`}>
                          {index > 0 ? (
                            <View
                              style={[
                                styles.separator,
                                { backgroundColor: colors.border },
                              ]}
                            />
                          ) : null}
                          <EventRow event={event} />
                        </View>
                      ))}
                  </View>
                </View>
              ))}
            </AnimatedView>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Activity" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { LogActivity }
```

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/LogActivity.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/Chip.swift`: see [Chip](https://ui.suiss.app/docs/components/chip.md)

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

- `swift/Components/Combobox.swift`: see [Combobox](https://ui.suiss.app/docs/components/combobox.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/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.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/LogActivity.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/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

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

- `kotlin/components/Collapsible.kt`: see [Collapsible](https://ui.suiss.app/docs/components/collapsible.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/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/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.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/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.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 { LogActivity } from "@/components/blocks/log-activity"
```

```tsx
<LogActivity />
```

Replace `EVENTS` with your audit log and `KINDS` with your event types. Wire `onFilterChange`, `onExport` (receives the current filter) and `onNavigate` (receives the selected tab) to your app.

