Skip to content
6

Changelog 1

Release notes with a sticky date rail, version badges, New / Improved / Fixed tags, a subscribe button, and a filter that narrows entries. Ships composable parts.

Preview

Installation

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

Code

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

import * as React from 'react';
import { Rss } 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 { ToggleGroup, ToggleGroupItem } from '@/registry/hirael/bases/radix/ui/toggle-group';

export type ChangelogTagKind = 'new' | 'improved' | 'fixed';
export type ChangelogFilterValue = 'all' | ChangelogTagKind;

const TAG_LABELS: Record<ChangelogTagKind, string> = {
  new: 'New',
  improved: 'Improved',
  fixed: 'Fixed',
};

const TAG_CLASSES: Record<ChangelogTagKind, string> = {
  new: 'bg-success/10 text-success',
  improved: 'bg-info/10 text-info',
  fixed: 'bg-warning/10 text-warning',
};

interface ChangelogContextValue {
  filter: ChangelogFilterValue;
  setFilter: (filter: ChangelogFilterValue) => void;
}

const ChangelogContext = React.createContext<ChangelogContextValue | null>(null);

const useChangelog = () => {
  const ctx = React.useContext(ChangelogContext);
  if (!ctx) {
    throw new Error('Changelog parts must be used within <Changelog>');
  }
  return ctx;
};

interface ChangelogProps extends React.ComponentProps<'section'> {
  filter?: ChangelogFilterValue;
  defaultFilter?: ChangelogFilterValue;
  onFilterChange?: (filter: ChangelogFilterValue) => void;
}

const Changelog = ({
  filter: filterProp,
  defaultFilter = 'all',
  onFilterChange,
  className,
  children,
  ...props
}: ChangelogProps) => {
  const [filterState, setFilterState] = React.useState(defaultFilter);
  const filter = filterProp ?? filterState;

  const setFilter = React.useCallback(
    (next: ChangelogFilterValue) => {
      if (filterProp === undefined) setFilterState(next);
      onFilterChange?.(next);
    },
    [filterProp, onFilterChange],
  );

  const value = React.useMemo(() => ({ filter, setFilter }), [filter, setFilter]);

  return (
    <ChangelogContext.Provider value={value}>
      <section data-slot="changelog" className={cn('bg-background py-16 sm:py-24', className)} {...props}>
        <div className="mx-auto flex w-full max-w-4xl flex-col gap-10 px-6 md:px-10">{children}</div>
      </section>
    </ChangelogContext.Provider>
  );
};

type ChangelogHeaderProps = React.ComponentProps<'header'>;

const ChangelogHeader = ({ className, ...props }: ChangelogHeaderProps) => {
  return (
    <header
      data-slot="changelog-header"
      className={cn(
        'flex flex-col gap-6 border-b border-border pb-8 sm:flex-row sm:items-end sm:justify-between',
        className,
      )}
      {...props}
    />
  );
};

type ChangelogTitleProps = React.ComponentProps<'h1'>;

const ChangelogTitle = ({ className, ...props }: ChangelogTitleProps) => {
  return (
    <h1
      data-slot="changelog-title"
      className={cn(
        'font-serif text-4xl font-medium leading-[1.04] tracking-tight text-foreground sm:text-5xl',
        className,
      )}
      {...props}
    />
  );
};

type ChangelogDescriptionProps = React.ComponentProps<'p'>;

const ChangelogDescription = ({ className, ...props }: ChangelogDescriptionProps) => {
  return (
    <p
      data-slot="changelog-description"
      className={cn('max-w-md text-sm text-muted-foreground', className)}
      {...props}
    />
  );
};

type ChangelogSubscribeProps = React.ComponentProps<typeof Button>;

const ChangelogSubscribe = ({ className, children = 'Subscribe', ...props }: ChangelogSubscribeProps) => {
  return (
    <Button variant="outline" size="sm" data-slot="changelog-subscribe" className={className} {...props}>
      <Rss aria-hidden />
      {children}
    </Button>
  );
};

const FILTERS: readonly { value: ChangelogFilterValue; label: string }[] = [
  { value: 'all', label: 'All' },
  { value: 'new', label: 'New' },
  { value: 'improved', label: 'Improved' },
  { value: 'fixed', label: 'Fixed' },
];

type ChangelogFilterProps = Omit<
  React.ComponentProps<typeof ToggleGroup>,
  'type' | 'value' | 'onValueChange' | 'defaultValue'
>;

const ChangelogFilter = ({ className, ...props }: ChangelogFilterProps) => {
  const { filter, setFilter } = useChangelog();
  return (
    <ToggleGroup
      type="single"
      variant="outline"
      size="sm"
      value={filter}
      onValueChange={(next) => {
        // Radix clears the value when the active item is clicked again;
        // keep one filter selected at all times.
        if (next) setFilter(next as ChangelogFilterValue);
      }}
      aria-label="Filter releases"
      data-slot="changelog-filter"
      className={className}
      {...props}
    >
      {FILTERS.map((item) => (
        <ToggleGroupItem key={item.value} value={item.value} className="text-xs">
          {item.label}
        </ToggleGroupItem>
      ))}
    </ToggleGroup>
  );
};

type ChangelogListProps = React.ComponentProps<'div'>;

const ChangelogList = ({ className, ...props }: ChangelogListProps) => {
  return <div data-slot="changelog-list" className={cn('flex flex-col', className)} {...props} />;
};

const formatDate = (date: string) => {
  const parsed = new Date(date);
  if (Number.isNaN(parsed.getTime())) return date;
  return new Intl.DateTimeFormat('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
  }).format(parsed);
};

interface ChangelogEntryDateProps extends Omit<React.ComponentProps<'time'>, 'children'> {
  date: string;
}

const ChangelogEntryDate = ({ date, className, ...props }: ChangelogEntryDateProps) => {
  return (
    <time
      dateTime={date}
      data-slot="changelog-entry-date"
      className={cn('font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground', className)}
      {...props}
    >
      {formatDate(date)}
    </time>
  );
};

type ChangelogEntryVersionProps = React.ComponentProps<typeof Badge>;

const ChangelogEntryVersion = ({ className, ...props }: ChangelogEntryVersionProps) => {
  return (
    <Badge
      variant="outline"
      data-slot="changelog-entry-version"
      className={cn('font-mono text-[11px] tabular-nums', className)}
      {...props}
    />
  );
};

interface ChangelogEntryProps extends React.ComponentProps<'article'> {
  version: string;
  /** ISO date, e.g. "2026-08-12". */
  date: string;
  /** Used by the filter; the entry hides itself when it has no matching tag. */
  tags?: readonly ChangelogTagKind[];
}

const ChangelogEntry = ({ version, date, tags = [], className, children, ...props }: ChangelogEntryProps) => {
  const { filter } = useChangelog();
  if (filter !== 'all' && !tags.includes(filter)) return null;

  return (
    <article
      data-slot="changelog-entry"
      data-version={version}
      className={cn(
        'grid grid-cols-1 gap-4 border-b border-border py-10 last:border-b-0 lg:grid-cols-[11rem_minmax(0,1fr)] lg:gap-10',
        className,
      )}
      {...props}
    >
      <div className="flex flex-row items-center gap-3 self-start lg:sticky lg:top-6 lg:flex-col lg:items-start lg:gap-2">
        <ChangelogEntryDate date={date} />
        <ChangelogEntryVersion>v{version}</ChangelogEntryVersion>
      </div>
      <div className="flex min-w-0 flex-col gap-4">{children}</div>
    </article>
  );
};

type ChangelogEntryTagsProps = React.ComponentProps<'div'>;

const ChangelogEntryTags = ({ className, ...props }: ChangelogEntryTagsProps) => {
  return (
    <div data-slot="changelog-entry-tags" className={cn('flex flex-wrap items-center gap-1.5', className)} {...props} />
  );
};

interface ChangelogEntryTagProps extends Omit<React.ComponentProps<typeof Badge>, 'variant' | 'children'> {
  kind: ChangelogTagKind;
  children?: React.ReactNode;
}

const ChangelogEntryTag = ({ kind, className, children, ...props }: ChangelogEntryTagProps) => {
  return (
    <Badge
      variant="secondary"
      data-slot="changelog-entry-tag"
      data-kind={kind}
      className={cn('font-mono text-[10px] uppercase tracking-[0.08em]', TAG_CLASSES[kind], className)}
      {...props}
    >
      {children ?? TAG_LABELS[kind]}
    </Badge>
  );
};

type ChangelogEntryTitleProps = React.ComponentProps<'h2'>;

const ChangelogEntryTitle = ({ className, ...props }: ChangelogEntryTitleProps) => {
  return (
    <h2
      data-slot="changelog-entry-title"
      className={cn('text-xl font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-2xl', className)}
      {...props}
    />
  );
};

type ChangelogEntryBodyProps = React.ComponentProps<'ul'>;

const ChangelogEntryBody = ({ className, ...props }: ChangelogEntryBodyProps) => {
  return (
    <ul
      data-slot="changelog-entry-body"
      className={cn(
        'flex list-disc flex-col gap-2 ps-5 text-sm leading-relaxed text-muted-foreground marker:text-border',
        className,
      )}
      {...props}
    />
  );
};

export {
  Changelog,
  ChangelogHeader,
  ChangelogTitle,
  ChangelogDescription,
  ChangelogSubscribe,
  ChangelogFilter,
  ChangelogList,
  ChangelogEntry,
  ChangelogEntryDate,
  ChangelogEntryVersion,
  ChangelogEntryTags,
  ChangelogEntryTag,
  ChangelogEntryTitle,
  ChangelogEntryBody,
  useChangelog,
};

const ENTRIES: readonly {
  version: string;
  date: string;
  title: string;
  tags: readonly ChangelogTagKind[];
  items: readonly string[];
}[] = [
  {
    version: '2.6.0',
    date: '2026-08-19',
    title: 'Scheduled digests and a faster inbox',
    tags: ['new', 'improved'],
    items: [
      'Send yourself a daily or weekly digest of unread threads. Pick the hour in Settings, Notifications.',
      'Inbox now loads the first 50 threads from cache, so it paints in under 100ms on a warm start.',
      'Keyboard: J and K move between threads, E archives, R replies.',
    ],
  },
  {
    version: '2.5.2',
    date: '2026-08-05',
    title: 'Fixes for shared drafts',
    tags: ['fixed'],
    items: [
      "Two people editing the same draft no longer overwrite each other's paragraph on save.",
      'Attachments over 25 MB show a clear error instead of a spinner that never stops.',
      'Fixed the search box losing focus when results update.',
    ],
  },
  {
    version: '2.5.0',
    date: '2026-07-22',
    title: 'Threads can be assigned',
    tags: ['new'],
    items: [
      'Assign a thread to a teammate from the thread header or with Shift+A. Assignees get a notification and the thread shows up in their Assigned view.',
      'Filter any view by assignee, including Unassigned.',
      'Zapier and the public API expose assignee on thread objects.',
    ],
  },
  {
    version: '2.4.1',
    date: '2026-07-08',
    title: 'Quieter notifications, better search',
    tags: ['improved', 'fixed'],
    items: [
      'Mentions inside quoted text no longer trigger a notification.',
      'Search understands from:, to:, and before: without quotes around dates.',
      'Fixed a case where archived threads reappeared after a reconnect.',
    ],
  },
];

const Changelog01Block = () => {
  return (
    <Changelog data-slot="changelog-01-block">
      <ChangelogHeader>
        <div className="flex flex-col gap-3">
          <ChangelogTitle>Changelog</ChangelogTitle>
          <ChangelogDescription>
            What shipped in Relay, most recent first. Releases go out every other Tuesday.
          </ChangelogDescription>
        </div>
        <div className="flex flex-wrap items-center gap-2">
          <ChangelogFilter />
          <ChangelogSubscribe />
        </div>
      </ChangelogHeader>

      <ChangelogList>
        {ENTRIES.map((entry) => (
          <ChangelogEntry key={entry.version} version={entry.version} date={entry.date} tags={entry.tags}>
            <ChangelogEntryTags>
              {entry.tags.map((tag) => (
                <ChangelogEntryTag key={tag} kind={tag} />
              ))}
            </ChangelogEntryTags>
            <ChangelogEntryTitle>{entry.title}</ChangelogEntryTitle>
            <ChangelogEntryBody>
              {entry.items.map((item) => (
                <li key={item}>{item}</li>
              ))}
            </ChangelogEntryBody>
          </ChangelogEntry>
        ))}
      </ChangelogList>
    </Changelog>
  );
};

export default Changelog01Block;

Dependencies

shadcn registry

badgebuttontoggle-group

npm

lucide-react

Shipped