# Audit Log

> A React audit log table: who did what, when and to which record, with filters, day groups, before / after diffs and a CSV export.

- Type: React component (Tables & Lists)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add audit-log`
- Docs: https://wingo-ui.com/components/audit-log

## Interaction

Search the log (actor, sentence, record and IP match, accents ignored), pick a period with quick ranges (Today, Last 7 days, Last 30 days, This month) and add filter chips for user, action, record type, severity or IP; the count updates and is announced. On desktop each event is a table row under a sticky day heading: click the row or its chevron button (Enter / Space) to open the before / after diff and the technical details, where IP, request id and metadata copy on click. The record in the sentence is a link. Tab reaches the toggles, links and copy buttons in order. Export CSV downloads exactly the events on screen (disabled when there are none). Below 768px the table becomes a feed of day groups: tap a row to expand it inline, the record link moves into the details, the period and filters open as bottom sheets, and Load more is a full-width button (or loads on scroll with autoLoadMore). Rows added by Load more slide in; the first page never animates. With reduced motion the rows and details appear without movement.

## Installation

```bash
npx wingo-ui@latest add audit-log
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/audit-log` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/audit-log.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Copy Button](https://wingo-ui.com/components/copy-button) ([Markdown](https://wingo-ui.com/components/copy-button.md))
- [Date Range Picker](https://wingo-ui.com/components/date-range-picker) ([Markdown](https://wingo-ui.com/components/date-range-picker.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Filter Builder](https://wingo-ui.com/components/filter-builder) ([Markdown](https://wingo-ui.com/components/filter-builder.md))
- [Progress](https://wingo-ui.com/components/progress) ([Markdown](https://wingo-ui.com/components/progress.md))
- [Search Input](https://wingo-ui.com/components/search-input) ([Markdown](https://wingo-ui.com/components/search-input.md))
- [Table](https://wingo-ui.com/components/table) ([Markdown](https://wingo-ui.com/components/table.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [Commerce](https://wingo-ui.com/components/commerce) ([Markdown](https://wingo-ui.com/components/commerce.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [Filters](https://wingo-ui.com/components/filters) ([Markdown](https://wingo-ui.com/components/filters.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Navigation](https://wingo-ui.com/components/navigation) ([Markdown](https://wingo-ui.com/components/navigation.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Relative time](https://wingo-ui.com/components/relative-time) ([Markdown](https://wingo-ui.com/components/relative-time.md))
- [Spreadsheet](https://wingo-ui.com/components/spreadsheet) ([Markdown](https://wingo-ui.com/components/spreadsheet.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { FilePlus2, FileX2, Pencil } from "lucide-react"
import { AuditLog, type AuditActionDef, type AuditEvent } from "@/components/ui/audit-log"

const actions: Record<string, AuditActionDef> = {
  "invoice.issued": { label: "issued", icon: <FilePlus2 />, tone: "info", group: "Invoices" },
  "invoice.cancelled": { label: "voided", icon: <FileX2 />, tone: "warning", group: "Invoices" },
  "client.updated": { label: "updated", icon: <Pencil />, group: "Clients" },
}

const events: AuditEvent[] = [
  {
    id: "e-1",
    at: "2026-09-24T16:35:00",
    actor: { id: "jessica", name: "Jessica Dunn", avatar: "/avatars/jessica.jpg" },
    action: "invoice.cancelled",
    target: { type: "invoice", id: "2026-0398", label: "Invoice #2026-0398", href: "/invoices/2026-0398" },
    severity: "warning",
    changes: [{ field: "status", label: "Status", type: "status", from: { label: "Issued", tone: "info" }, to: { label: "Voided", tone: "danger" } }],
    ip: "93.114.52.211",
    device: "Firefox · Windows",
  },
  {
    id: "e-2",
    at: "2026-09-24T16:12:00",
    actor: { id: "anna", name: "Anna Price" },
    action: "client.updated",
    target: { type: "client", id: "c04", label: "Greenleaf Pharmacy" },
    changes: [{ field: "balance", label: "Balance", type: "money", from: 7315.2, to: 0 }],
  },
]

export function ActivityLog() {
  return (
    <AuditLog
      events={events}
      actions={actions}
      targetTypes={{ invoice: "Invoice", client: "Client" }}
      currency="USD"
      exportFileName="activity-log"
      labels={{ search: "Search activity", export: "Download CSV" }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `events` | `AuditEvent[]` |  | The events, newest first ({ id, at, actor: { id, name, avatar?, kind? }, action, target?: { type, id, label, href? }, summary?, changes?, severity?, ip?, device?, location?, requestId?, metadata? }). Not sorted unless sortOrder says so. |
| `actions?` | `Record<string, AuditActionDef>` |  | Labels, icons, tones and groups per action key: { "invoice.cancelled": { label: "voided", icon, tone: "warning", group: "Invoices" } }. An unknown key prints the key. |
| `targetTypes?` | `Record<string, string>` |  | Record type names for the filter and the export: { invoice: "Invoice" }. |
| `mode?` | `"client" \| "server"` | `"client"` | client filters, searches and limits the period here. server leaves the events alone and only calls onQueryChange; pass the result and totalCount. |
| `onQueryChange?` | `(query: { search: string; period: DateRange \| null; filter: FilterGroup }) => void` |  | Called 300ms after the search, the period or the filter last changed (never on mount). |
| `defaultSearch?` | `string` | `""` | The search the log starts with. Use search / onSearchChange to control it. |
| `search?` | `string` |  | The search, controlled. |
| `onSearchChange?` | `(search: string) => void` |  | Every keystroke of the search. |
| `filter?` | `FilterGroup` |  | The filter, controlled (a lib/filters group). |
| `defaultFilter?` | `FilterGroup` |  | The filter the log starts with; an empty AND group by default. |
| `onFilterChange?` | `(filter: FilterGroup) => void` |  | Every change of the filter chips. |
| `filterFields?` | `FilterBuilderField<AuditEvent>[]` |  | The filterable fields. Default defaultAuditFilterFields(actions, the events' actors): user, action, record type, severity, IP. |
| `views?` | `FilterView[]` |  | Saved views in the builder's menu ("Deletions only", "Critical actions"). |
| `period?` | `DateRange \| null` |  | The period, controlled. |
| `defaultPeriod?` | `DateRange \| null` |  | The period the log starts with; null shows everything. |
| `onPeriodChange?` | `(period: DateRange \| null) => void` |  | A picked range, or null when cleared. |
| `groupByDay?` | `boolean` | `true` | Day headings ("Today", "Yesterday", "Monday, September 21") that stick under the table header. Events out of order land under their own day. |
| `sortOrder?` | `"desc" \| "asc" \| "none"` | `"none"` | none keeps the order you pass; desc and asc sort by time. |
| `expandable?` | `boolean` | `true` | Rows with changes or details expand into the before / after diff and the IP, device, location and request id. |
| `expandedKeys?` | `string[]` |  | The expanded event ids, controlled. |
| `defaultExpandedKeys?` | `string[]` |  | Event ids expanded at first. |
| `onExpandedKeysChange?` | `(keys: string[]) => void` |  | A row was opened or closed. |
| `timeFormat?` | `"relative" \| "time" \| "datetime"` | `"relative"` | relative: "5 min ago" today and the time under older day headings. time: the clock (with the date when not grouped). datetime: date and time. |
| `showIp?` | `boolean` | `true` | The IP column on desktop. The IP stays in the details either way. |
| `showSeverity?` | `boolean` | `true` | The severity column: notice, warning and critical as soft badges with an icon; info stays quiet. |
| `searchable?` | `boolean` | `true` | The search field at the start of the toolbar. |
| `filterable?` | `boolean` | `true` | The filter builder: chips on desktop, a Filters button with a bottom sheet on phones. |
| `showPeriod?` | `boolean` | `true` | The period picker with quick ranges. |
| `exportable?` | `boolean` | `true` | The export button: onExport, or a CSV download of the events on screen. Disabled with no events. |
| `onExport?` | `(events: AuditEvent[], query: AuditLogQuery) => (void \| Promise<unknown>)` |  | Replaces the built-in CSV (a server export). A returned promise shows the button loading; a rejection is announced. |
| `exportFileName?` | `string` | `"audit-log"` | The CSV name before the date: "audit-log-2026-09-24.csv". |
| `csvDelimiter?` | `"," \| ";"` | `","` | The CSV separator. ";" opens straight in Excel set to a European locale, which reads "," as the decimal mark. |
| `onEventClick?` | `(event: AuditEvent) => void` |  | The record link's click when the target has no href. |
| `renderSummary?` | `(event: AuditEvent, parts: { actor: ReactNode; verb: ReactNode; target: ReactNode }) => ReactNode` |  | A custom sentence from the three parts. event.summary replaces the sentence for one event. |
| `density?` | `"compact" \| "default"` | `"default"` | Row height: 48px with a second line for times and actor kinds, or 36px on one line. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Corners of the search, the period, the export and load-more buttons. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The active filter chips and the period's selection. |
| `loading?` | `boolean` | `false` | Skeleton rows with the real shape (feed rows on phones) in place of the events. |
| `skeletonCount?` | `number` | `8` | How many skeleton rows loading shows (phones show at most 6). |
| `refreshing?` | `boolean` | `false` | Keeps the rows and shows a thin indeterminate bar at the top while a request runs. |
| `error?` | `ReactNode` | `""` | Replaces the rows with an error state and a Try again button (onRetry). Text or a node is the message; true shows labels.error. |
| `onRetry?` | `() => (void \| Promise<unknown>)` |  | The error state's Try again; a promise shows it loading. |
| `emptyState?` | `ReactNode` |  | Replaces the empty state when there are no events at all. |
| `hasMore?` | `boolean` | `false` | Shows Load more under the rows. Big logs page on the server with it. |
| `onLoadMore?` | `() => (void \| Promise<unknown>)` |  | Loads the next page; a returned promise keeps the button loading. |
| `loadingMore?` | `boolean` | `false` | Shows Load more in its loading state (for a request started elsewhere). |
| `autoLoadMore?` | `boolean` | `false` | Loads the next page when the Load more button scrolls within 200px of view. |
| `maxRows?` | `number` | `200` | Renders at most this many events (0 renders all) and says so under the list. Beyond a few hundred, page on the server with hasMore. |
| `totalCount?` | `number` |  | The count in the toolbar when the server knows the total (server mode). |
| `currency?` | `string` | `"USD"` | ISO 4217 code for money changes without their own currency. |
| `now?` | `Date \| number` |  | The reference time for relative times and day headings. Default: the current minute (useNow). |
| `locale?` | `string` | `""` | Dates, times, numbers and money. Empty uses the UIProvider locale, then the browser's. |
| `linkComponent?` | `(props: LinkComponentProps) => ReactNode` |  | The router link for record links (default the navigation provider's, else <a>). |
| `labels?` | `Partial<AuditLogLabels>` |  | Every string, English by default (DEFAULT_AUDIT_LOG_LABELS): search, period, export, count(n, formatted), column names, severities, expand / collapse, emptyValue, fieldCount, rangePresets, csvHeaders and more. |
| `filterBuilderProps?` | `Partial<FilterBuilderProps<AuditEvent>>` |  | Extra props for the filter builder: its labels, applyMode, onSaveView, defaultActiveViewId. |
| `dateRangePickerProps?` | `Partial<DateRangePickerProps>` |  | Extra props for the period picker: its labels, min, numberOfMonths, your own presets. |
| `classNames?` | `Partial<Record<"toolbar" \| "search" \| "period" \| "filters" \| "export" \| "count" \| "refreshing" \| "table" \| "group" \| "row" \| "details" \| "diff" \| "meta" \| "feed" \| "item" \| "more" \| "empty" \| "error", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root. Set --table-sticky-top here to sit the sticky header under an app bar. |

## Parts

### AuditDiff (component)

The before / after table alone: values formatted by type (money, number, date, boolean, status badge, list), empty values muted. auto stacks the rows below 448px of room. Props: { changes: AuditChange[]; currency?: string; locale?: string; layout?: "auto" | "table" | "stack"; labels?; classNames? }.

### defaultAuditFilterFields (function)

The default filter fields: user, action, record type, severity and IP, for your own builder or a server that filters with lib/filters. Signature: (actions, actors, options?: { labels?; targetTypes?; types? }) => FilterBuilderField<AuditEvent>[].

### auditEventsToCsv (function)

The export's rows (header first) for toCsv / downloadCsv from lib/spreadsheet. Signature: (events: AuditEvent[], options?: { actions?; labels?; targetTypes?; locale?; currency?; header? }) => (string | null)[][].

### formatAuditValue (function)

A change value as text, the way the diff prints it. Signature: (value: unknown, change: AuditChange, context: { locale; currency; labels }) => string.

### visibleChanges (function)

The changes with a from or a to (the ones the diff shows). Signature: (changes?: AuditChange[]) => AuditChange[].

### DEFAULT_AUDIT_LOG_LABELS (constant)

The English strings, to spread under your translations. Type: AuditLogLabels.

## Examples

- **Phone feed**: At 390px the table becomes a day-grouped feed; tap a row to open its changes.
- **Server mode**: Search, period and filters go to a fake endpoint with 600ms latency.
- **AuditDiff**: The before / after table of a client edit, on its own.
- **Saved views**: Built-in views for deletions, critical actions and integrations.
- **States**: Loading skeletons, an error with retry and a search with no results.
- **Compact**: Dense rows, full timestamps and no IP column.

## See also

- [Filter Builder](https://wingo-ui.com/components/filter-builder) ([Markdown](https://wingo-ui.com/components/filter-builder.md))
- [Table](https://wingo-ui.com/components/table) ([Markdown](https://wingo-ui.com/components/table.md))
- [Timeline](https://wingo-ui.com/components/timeline) ([Markdown](https://wingo-ui.com/components/timeline.md))
- [Spreadsheet](https://wingo-ui.com/components/spreadsheet) ([Markdown](https://wingo-ui.com/components/spreadsheet.md))
- [Filters](https://wingo-ui.com/components/filters) ([Markdown](https://wingo-ui.com/components/filters.md))

---

Canonical: https://wingo-ui.com/components/audit-log
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
