Skip to content
38

Changelog 02

Public roadmap board with Now, Next and Later columns. Every item can be upvoted, the board sorts by votes or by date, and on small screens tabs pick the column.

Preview

npx shadcn@latest add https://hirael.com/r/changelog-02.json

Installation

npx shadcn@latest add https://hirael.com/r/changelog-02.json

Code

components/blocks/changelog-02.tsx
'use client';

import * as React from 'react';
import { ArrowRight, Check, ChevronUp } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/registry/hirael/bases/radix/ui/tabs';
import { ToggleGroup, ToggleGroupItem } from '@/registry/hirael/bases/radix/ui/toggle-group';

const EASE = 'ease-[cubic-bezier(0.22,1,0.36,1)]';
const ENTER = `animate-in fade-in slide-in-from-bottom-4 duration-500 ${EASE} fill-mode-both motion-reduce:animate-none`;
const SWAP = `animate-in fade-in slide-in-from-bottom-2 duration-300 ${EASE} fill-mode-both motion-reduce:animate-none`;

const stagger = (index: number, step = 60): React.CSSProperties => ({ animationDelay: `${index * step}ms` });

type ColumnId = 'now' | 'next' | 'later';
type Sort = 'votes' | 'newest';

interface RoadmapItem {
  id: string;
  column: ColumnId;
  title: string;
  description: string;
  tag: string;
  votes: number;
  /** ISO date the item was added to the board. */
  added: string;
  /** Display date when the item has shipped, e.g. "Aug 28". */
  shipped?: string;
}

const COLUMNS: readonly { id: ColumnId; title: string; meaning: string }[] = [
  { id: 'now', title: 'Now', meaning: 'Being built this cycle' },
  { id: 'next', title: 'Next', meaning: 'Planned for the next cycle' },
  { id: 'later', title: 'Later', meaning: 'On our list, not scheduled yet' },
];

const ITEMS: readonly RoadmapItem[] = [
  {
    id: 'webhooks-retry',
    column: 'now',
    title: 'Webhook retries with backoff',
    description: 'Failed deliveries retry for 24 hours and show every attempt in the log.',
    tag: 'API',
    votes: 212,
    added: '2026-06-02',
    shipped: 'Aug 28',
  },
  {
    id: 'inline-comments',
    column: 'now',
    title: 'Inline comments on drafts',
    description: 'Select any sentence in a draft and leave a note for a teammate.',
    tag: 'Editor',
    votes: 348,
    added: '2026-05-14',
  },
  {
    id: 'annual-invoices',
    column: 'now',
    title: 'Annual plans with one invoice',
    description: 'Pay for a year up front and get a single invoice with your tax ID on it.',
    tag: 'Billing',
    votes: 97,
    added: '2026-07-21',
  },
  {
    id: 'scim',
    column: 'now',
    title: 'SCIM provisioning',
    description: 'Add and remove seats from Okta or Entra ID without touching Hirael.',
    tag: 'Admin',
    votes: 164,
    added: '2026-04-30',
  },
  {
    id: 'rate-limit-headers',
    column: 'next',
    title: 'Rate limit headers on every response',
    description: 'Know how many requests you have left before you hit the limit.',
    tag: 'API',
    votes: 131,
    added: '2026-08-11',
  },
  {
    id: 'version-history',
    column: 'next',
    title: 'Version history for templates',
    description: 'See who changed a saved reply and restore any earlier version.',
    tag: 'Editor',
    votes: 276,
    added: '2026-07-02',
  },
  {
    id: 'usage-alerts',
    column: 'next',
    title: 'Usage alerts before overages',
    description: 'Get an email when a workspace reaches 80 percent of its monthly quota.',
    tag: 'Billing',
    votes: 189,
    added: '2026-08-25',
  },
  {
    id: 'offline-mode',
    column: 'later',
    title: 'Offline mode for the desktop app',
    description: 'Read and draft replies on a plane, then send when you reconnect.',
    tag: 'Desktop',
    votes: 402,
    added: '2026-03-18',
  },
  {
    id: 'graphql',
    column: 'later',
    title: 'GraphQL endpoint',
    description: 'Fetch threads, contacts, and tags in one request instead of three.',
    tag: 'API',
    votes: 88,
    added: '2026-09-03',
  },
  {
    id: 'multi-currency',
    column: 'later',
    title: 'Pay in EUR and GBP',
    description: 'Invoices and card charges in your own currency, no conversion fees.',
    tag: 'Billing',
    votes: 145,
    added: '2026-06-19',
  },
];

// Sorts on the stored count so an item doesn't jump from under the cursor when voted.
const sortItems = (items: readonly RoadmapItem[], sort: Sort) =>
  [...items].sort((a, b) => (sort === 'votes' ? b.votes - a.votes : b.added.localeCompare(a.added)));

const formatAdded = (date: string) =>
  new Intl.DateTimeFormat('en-US', { month: 'short', day: '2-digit', timeZone: 'UTC' }).format(new Date(date));

interface UpvoteProps {
  count: number;
  voted: boolean;
  title: string;
  onToggle: () => void;
}

const Upvote = ({ count, voted, title, onToggle }: UpvoteProps) => {
  return (
    <Button
      type="button"
      variant={voted ? 'secondary' : 'outline'}
      size="sm"
      data-slot="roadmap-upvote"
      aria-pressed={voted}
      aria-label={`${voted ? 'Remove vote for' : 'Vote for'} ${title}, ${count} votes`}
      onClick={onToggle}
      className="h-auto w-12 shrink-0 flex-col"
    >
      <ChevronUp
        aria-hidden
        className={cn('size-4 transition-transform duration-150 ease-out', voted && '-translate-y-px')}
      />
      <span className="relative block h-4 overflow-hidden text-xs leading-4">
        <span
          key={count}
          className={cn(
            'block animate-in tabular-nums duration-250 fill-mode-both fade-in motion-reduce:animate-none',
            EASE,
            voted ? 'slide-in-from-bottom-2' : 'slide-in-from-top-2',
          )}
        >
          {count}
        </span>
      </span>
    </Button>
  );
};

const Changelog02 = () => {
  const [sort, setSort] = React.useState<Sort>('votes');
  const [activeColumn, setActiveColumn] = React.useState<ColumnId>('now');
  const [votes, setVotes] = React.useState<ReadonlySet<string>>(() => new Set(['version-history']));

  const toggleVote = (id: string) => {
    setVotes((current) => {
      const next = new Set(current);
      if (next.has(id)) next.delete(id);
      else next.add(id);

      return next;
    });
  };

  const sorted = sortItems(ITEMS, sort);

  return (
    <section data-slot="roadmap" aria-labelledby="changelog-02-heading" className="bg-background py-20 sm:py-28">
      <div className="mx-auto flex w-full max-w-6xl flex-col gap-10 px-6 md:gap-12 md:px-10">
        <header
          data-slot="roadmap-header"
          className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between md:gap-10"
        >
          <div className="flex max-w-xl flex-col gap-4">
            <span className={cn(ENTER, 'text-xs text-muted-foreground uppercase')}>Public roadmap</span>
            <h2
              id="changelog-02-heading"
              style={stagger(1, 80)}
              className={cn(ENTER, 'text-3xl font-semibold tracking-tight text-balance sm:text-4xl')}
            >
              What we are working on
            </h2>
            <p style={stagger(2, 80)} className={cn(ENTER, 'text-base leading-relaxed text-muted-foreground')}>
              Vote for the things you need. We read every vote when we plan a cycle, and cycles last six weeks.
            </p>
          </div>

          <div style={stagger(3, 80)} className={cn(ENTER, 'flex items-center gap-3')}>
            <span id="changelog-02-sort" className="text-xs text-muted-foreground uppercase">
              Sort
            </span>
            <ToggleGroup
              type="single"
              variant="outline"
              size="sm"
              value={sort}
              onValueChange={(next) => {
                // Keep one option selected when the active one is clicked again.
                if (next) setSort(next as Sort);
              }}
              aria-labelledby="changelog-02-sort"
              data-slot="roadmap-sort"
            >
              <ToggleGroupItem value="votes">Most votes</ToggleGroupItem>
              <ToggleGroupItem value="newest">Newest</ToggleGroupItem>
            </ToggleGroup>
          </div>
        </header>

        <div style={stagger(4, 80)} className={cn(ENTER, 'flex flex-col gap-4')}>
          <Tabs
            value={activeColumn}
            onValueChange={(value) => setActiveColumn(value as ColumnId)}
            aria-label="Roadmap column"
            className="md:hidden"
          >
            <TabsList variant="line" className="w-full justify-start">
              {COLUMNS.map((column) => (
                <TabsTrigger key={column.id} value={column.id} className="flex-none">
                  {column.title}
                  <span className="text-xs text-muted-foreground tabular-nums">
                    {ITEMS.filter((item) => item.column === column.id).length}
                  </span>
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>

          <div
            data-slot="roadmap-board"
            className="grid grid-cols-1 overflow-hidden rounded-xl border border-border bg-card/40 md:grid-cols-3 md:divide-x md:divide-border"
          >
            {COLUMNS.map((column) => {
              const items = sorted.filter((item) => item.column === column.id);

              return (
                <div
                  key={column.id}
                  data-slot="roadmap-column"
                  data-column={column.id}
                  className={cn('flex-col', activeColumn === column.id ? 'flex' : 'hidden md:flex')}
                >
                  <div
                    data-slot="roadmap-column-header"
                    className="flex flex-col gap-1 border-b border-border px-5 py-4"
                  >
                    <div className="flex items-center gap-2">
                      <h3 className="text-sm font-medium">{column.title}</h3>
                      <span className="text-xs text-muted-foreground tabular-nums">{items.length}</span>
                    </div>
                    <p className="text-xs text-muted-foreground">{column.meaning}</p>
                  </div>

                  <ul key={sort} className="flex flex-col divide-y divide-border">
                    {items.map((item, index) => {
                      const voted = votes.has(item.id);

                      return (
                        <li
                          key={item.id}
                          data-slot="roadmap-item"
                          style={stagger(index, 50)}
                          className={cn(SWAP, 'flex items-start gap-4 px-5 py-4')}
                        >
                          <div className="flex min-w-0 flex-1 flex-col gap-2">
                            <div className="flex flex-col gap-1">
                              <h4 className="text-sm leading-snug font-medium">{item.title}</h4>
                              <p className="text-sm leading-relaxed text-muted-foreground">{item.description}</p>
                            </div>
                            <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
                              <Badge variant="outline">{item.tag}</Badge>
                              {item.shipped ? (
                                <span className="inline-flex items-center gap-1 text-xs text-success">
                                  <Check aria-hidden className="size-3" />
                                  Shipped <span className="tabular-nums">{item.shipped}</span>
                                </span>
                              ) : (
                                <span className="text-xs text-muted-foreground">
                                  Added <span className="tabular-nums">{formatAdded(item.added)}</span>
                                </span>
                              )}
                            </div>
                          </div>
                          <Upvote
                            title={item.title}
                            count={item.votes + (voted ? 1 : 0)}
                            voted={voted}
                            onToggle={() => toggleVote(item.id)}
                          />
                        </li>
                      );
                    })}
                  </ul>
                </div>
              );
            })}
          </div>
        </div>

        <footer
          data-slot="roadmap-footer"
          className="flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between"
        >
          <span>Not on the board? Tell us what is missing and how you would use it.</span>
          <Button variant="link" className="group/suggest h-auto w-fit" asChild>
            <a href="#">
              Suggest a feature
              <ArrowRight
                aria-hidden
                className="size-3.5 transition-transform duration-150 ease-out group-hover/suggest:translate-x-0.5 rtl:rotate-180 rtl:group-hover/suggest:-translate-x-0.5"
              />
            </a>
          </Button>
        </footer>
      </div>
    </section>
  );
};

export default Changelog02;

Dependencies

shadcn registry

badgebuttontabstoggle-group

npm

lucide-react

Shipped