Date Cell
Data Display

Date Cell

A compact semantic date for tables and lists — calendar-page tile with a primary today band, two-line stack, and inline variants with Today/Yesterday wording and current-year elision.

Install

npx shadcn@latest add @paragon/date-cell

date-cell.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/* ------------------------------------------------------------------
 * Hand-rolled date formatting — no date libraries. Day comparisons go
 * through Date.UTC on local calendar components so DST can never skew
 * a "Today" badge.
 * ------------------------------------------------------------------ */

const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
] as const;

const WEEKDAYS = [
  "Sunday",
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
] as const;

const mon3 = (d: Date) => MONTHS[d.getMonth()].slice(0, 3);
const wd3 = (d: Date) => WEEKDAYS[d.getDay()].slice(0, 3);

/** Whole calendar days from `b` to `a` (positive when `a` is later). */
function calendarDayDiff(a: Date, b: Date): number {
  return Math.round(
    (Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()) -
      Date.UTC(b.getFullYear(), b.getMonth(), b.getDate())) /
      86_400_000,
  );
}

/** "9:41 AM" — deterministic, locale-independent. */
function formatTime(d: Date): string {
  const h12 = d.getHours() % 12 || 12;
  return `${h12}:${String(d.getMinutes()).padStart(2, "0")} ${d.getHours() < 12 ? "AM" : "PM"}`;
}

const pad2 = (n: number) => String(n).padStart(2, "0");

function isoDate(d: Date, withTime: boolean): string {
  const day = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
  return withTime ? `${day}T${pad2(d.getHours())}:${pad2(d.getMinutes())}` : day;
}

export interface DateCellProps
  extends Omit<React.ComponentProps<"time">, "children" | "dateTime"> {
  date?: Date;
  variant?: "tile" | "stack" | "inline";
  /** Reference "today". Enables Today/Yesterday/Tomorrow wording, the
   * today tile accent, and current-year elision. Pass a fixed date for
   * deterministic renders. */
  now?: Date;
  showWeekday?: boolean;
  showTime?: boolean;
  /** Say "Today" / "Yesterday" / "Tomorrow" when within a day of `now`. */
  relativeDays?: boolean;
}

// Fixed default so the zero-props render never touches the wall clock.
const DEFAULT_DATE = new Date(2026, 6, 14, 9, 41);

/**
 * A compact date for tables and lists in three shapes: a calendar-page
 * tile whose month band turns primary on today, a two-line stack, and a
 * single inline run. Today/Yesterday/Tomorrow wording and current-year
 * elision keep the copy product-real; everything is a semantic <time>.
 */
export function DateCell({
  date = DEFAULT_DATE,
  variant = "tile",
  now,
  showWeekday = false,
  showTime = false,
  relativeDays = true,
  className,
  ...props
}: DateCellProps) {
  const dayDiff = now ? calendarDayDiff(date, now) : null;
  const relativeWord =
    relativeDays && dayDiff !== null && Math.abs(dayDiff) <= 1
      ? dayDiff === 0
        ? "Today"
        : dayDiff === 1
          ? "Tomorrow"
          : "Yesterday"
      : null;
  const isToday = dayDiff === 0;
  // Elide the year only when a reference `now` proves it is the current one.
  const withYear = !now || date.getFullYear() !== now.getFullYear();
  const dateLine = (weekday: boolean) =>
    `${weekday ? `${wd3(date)}, ` : ""}${mon3(date)} ${date.getDate()}${withYear ? `, ${date.getFullYear()}` : ""}`;

  const absolute = `${WEEKDAYS[date.getDay()]}, ${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}${showTime ? ` at ${formatTime(date)}` : ""}`;
  const dateTime = isoDate(date, showTime);

  if (variant === "tile") {
    return (
      <time
        dateTime={dateTime}
        className={cn(
          "flex w-12 shrink-0 flex-col overflow-hidden rounded-lg bg-card text-center shadow-border",
          className,
        )}
        {...props}
      >
        <span className="sr-only">{absolute}</span>
        <span
          aria-hidden
          className={cn(
            "py-[3px] text-[10px] leading-none font-semibold tracking-widest uppercase",
            isToday
              ? "bg-primary text-primary-foreground"
              : "bg-secondary/80 text-muted-foreground dark:bg-secondary/50",
          )}
        >
          {mon3(date)}
        </span>
        <span
          aria-hidden
          className={cn(
            "pt-1 text-xl leading-6 font-semibold text-foreground tabular-nums",
            !showWeekday && "pb-1.5",
          )}
        >
          {date.getDate()}
        </span>
        {showWeekday && (
          <span
            aria-hidden
            className="pb-1 text-[10px] leading-3 text-muted-foreground"
          >
            {wd3(date)}
          </span>
        )}
      </time>
    );
  }

  if (variant === "stack") {
    const secondary = [
      ...(showWeekday && !relativeWord ? [wd3(date)] : []),
      ...(showTime ? [formatTime(date)] : []),
    ].join(" · ");
    return (
      <time
        dateTime={dateTime}
        className={cn("flex flex-col gap-px", className)}
        {...props}
      >
        <span className="text-sm leading-5 font-medium text-foreground tabular-nums">
          {relativeWord ?? dateLine(false)}
        </span>
        {secondary && (
          <span className="text-xs leading-4 text-muted-foreground tabular-nums">
            {secondary}
          </span>
        )}
      </time>
    );
  }

  return (
    <time
      dateTime={dateTime}
      className={cn("inline-flex items-baseline gap-1.5 whitespace-nowrap", className)}
      {...props}
    >
      <span className="text-sm font-medium text-foreground tabular-nums">
        {relativeWord ?? dateLine(showWeekday)}
      </span>
      {showTime && (
        <span className="text-xs text-muted-foreground tabular-nums">
          · {formatTime(date)}
        </span>
      )}
    </time>
  );
}