Activity Feed

A chronological list of events, with a connecting timeline rail.

  1. Deployed to production

    Build 1420 · main
  2. All checks passed

    24 tests, 0 failures
  3. Opened pull request #482

    Add keyboard shortcuts to the command palette
  4. Created branch feat/shortcuts

    From main

Installation

Terminal
pnpm dlx @dowel-ui/cli add activity-feed

npm packages installed: radix-ui.

Accessibility

An ordered list, so position and count are announced — the order is the meaning here. The rail and the indicators are decorative; the item text has to say what happened. ActivityTime requires a machine-readable dateTime, because a relative label like "2 hours ago" is ambiguous outside the moment it was rendered.

Props

ActivityFeed

Plus every attribute of <ol>.

ActivityItem

PropTypeDefault
last

Draws no connecting line below. Set this on the last item.

boolean

Plus every attribute of <li>.

ActivityIndicator

PropTypeDefault
asChildboolean

Plus every attribute of <div>.

ActivityContent

Plus every attribute of <div>.

ActivityTitle

Plus every attribute of <p>.

ActivityTime

PropTypeDefault
dateTime (required)

Machine-readable timestamp. Required — a relative label alone is ambiguous.

string

Plus every attribute of <time>.

ActivityDescription

Plus every attribute of <div>.

Quality

7/7 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokensdoes not apply
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Used in

Whole screens assembled from this component. Installing one brings this and everything else it needs with it.

Source

This is exactly what dowel add activity-feed writes into your project, with imports rewritten to your own path alias.

ui/activity-feed.tsx
import { Slot } from "radix-ui";
import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils";

/**
 * A chronological list of events.
 *
 * An ordered list underneath, because the order is the meaning: a screen reader
 * announces the position and count, and "3 of 12" is information a row of divs
 * cannot convey. The connecting line is drawn with a pseudo-element rather than
 * a real node so it never appears in the accessibility tree.
 */
export function ActivityFeed({ className, ...props }: ComponentPropsWithRef<"ol">) {
  return <ol data-slot="activity-feed" className={cn("flex flex-col", className)} {...props} />;
}

export interface ActivityItemProps extends ComponentPropsWithRef<"li"> {
  /** Draws no connecting line below. Set this on the last item. */
  last?: boolean;
}

export function ActivityItem({ className, last, ...props }: ActivityItemProps) {
  return (
    <li
      data-slot="activity-item"
      data-last={last || undefined}
      className={cn(
        "relative flex gap-3 pb-6 last:pb-0",
        // The rail is a pseudo-element on the item, so it is decorative by
        // construction rather than by remembering an aria-hidden.
        "before:absolute before:start-[0.6875rem] before:top-7 before:bottom-1 before:w-px before:bg-border",
        "last:before:hidden data-[last]:before:hidden",
        className,
      )}
      {...props}
    />
  );
}

export interface ActivityIndicatorProps extends ComponentPropsWithRef<"div"> {
  asChild?: boolean;
}

/** The marker on the rail. Decorative: the item's text says what happened. */
export function ActivityIndicator({ className, asChild, ...props }: ActivityIndicatorProps) {
  const Comp = asChild ? Slot.Root : "div";

  return (
    <Comp
      data-slot="activity-indicator"
      aria-hidden="true"
      className={cn(
        "relative z-[var(--z-base)] grid size-6 shrink-0 place-items-center rounded-full",
        "border border-border bg-background text-muted-foreground",
        "[&_svg:not([class*='size-'])]:size-3",
        className,
      )}
      {...props}
    />
  );
}

export function ActivityContent({ className, ...props }: ComponentPropsWithRef<"div">) {
  return (
    <div
      data-slot="activity-content"
      className={cn("flex min-w-0 flex-1 flex-col gap-1 pt-0.5", className)}
      {...props}
    />
  );
}

export function ActivityTitle({ className, ...props }: ComponentPropsWithRef<"p">) {
  return (
    <p
      data-slot="activity-title"
      className={cn("text-sm leading-snug text-foreground", className)}
      {...props}
    />
  );
}

export interface ActivityTimeProps extends ComponentPropsWithRef<"time"> {
  /** Machine-readable timestamp. Required — a relative label alone is ambiguous. */
  dateTime: string;
}

/**
 * When the event happened.
 *
 * `dateTime` is required rather than optional: "2 hours ago" is meaningless in
 * a page read a day later, or by anything parsing the feed, and a `<time>` with
 * no datetime is just a span.
 */
export function ActivityTime({ className, dateTime, ...props }: ActivityTimeProps) {
  return (
    <time
      data-slot="activity-time"
      dateTime={dateTime}
      className={cn("text-xs text-muted-foreground", className)}
      {...props}
    />
  );
}

export function ActivityDescription({ className, ...props }: ComponentPropsWithRef<"div">) {
  return (
    <div
      data-slot="activity-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  );
}