← Dashboard
Zubeyr··Blocksactivitylists

Timeline

An activity timeline with a comment box on top and dated events below, newest first. Comments post instantly and call onComment.

Live demo ↗

Installation

Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.

pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/timeline.json
  • →Files land in components/dashboard.

Usage

example.tsx
import { Timeline } from '@/components/dashboard/timeline'

export default function Demo() {
  return (
    <div className="grid min-h-svh place-items-center p-8">
      <Timeline
        className="w-full max-w-sm"
        events={[
          {
            id: '1',
            content: 'Order confirmation email was sent to the customer.',
            time: '1:36 PM',
          },
          {
            id: '2',
            content: 'A $108.99 payment was processed on Visa •••• 3840.',
            time: '1:36 PM',
          },
          { id: '3', content: 'Order was placed from the Online Store.', time: '1:35 PM' },
        ]}
      />
    </div>
  )
}

Source code

timeline.tsx
'use client'

import { useState } from 'react'

import { cn } from '@/lib/utils'

export type TimelineEvent = { id: string; content: React.ReactNode; time: string }

type TimelineProps = {
  events: TimelineEvent[]
  onComment?: (text: string) => void
  placeholder?: string
  className?: string
}

const timeFormat = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' })

export function Timeline({
  events,
  onComment,
  placeholder = 'Leave a comment',
  className,
}: TimelineProps) {
  const [draft, setDraft] = useState('')
  const [posted, setPosted] = useState<TimelineEvent[]>([])

  const post = () => {
    const text = draft.trim()
    if (!text) return
    setPosted([
      { id: `c-${Date.now()}`, content: text, time: timeFormat.format(new Date()) },
      ...posted,
    ])
    onComment?.(text)
    setDraft('')
  }

  return (
    <div className={cn('flex flex-col gap-4', className)}>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          post()
        }}
        className="flex items-center gap-2 rounded-lg border border-input bg-card p-1 pl-3 focus-within:border-ring"
      >
        <input
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          placeholder={placeholder}
          aria-label={placeholder}
          className="h-7 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
        />
        {draft.trim() && (
          <button
            type="submit"
            className="h-7 rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground"
          >
            Post
          </button>
        )}
      </form>
      <ol className="flex flex-col">
        {[...posted, ...events].map((event, i, all) => (
          <li key={event.id} className="relative grid grid-cols-[1rem_1fr_auto] gap-2 pb-5">
            {i < all.length - 1 && (
              <span aria-hidden className="absolute top-3 bottom-0 left-[3.5px] w-px bg-border" />
            )}
            <span aria-hidden className="mt-1.5 size-2 rounded-full bg-muted-foreground/40" />
            <span className="text-sm">{event.content}</span>
            <time className="text-xs whitespace-nowrap text-muted-foreground">{event.time}</time>
          </li>
        ))}
      </ol>
    </div>
  )
}