Dice UI
Components

Relative Time Card

A hover card that displays relative time relative to local time with timezone information.

Basic usage
Different variants
With time in the future
Multiple timezones
Custom trigger
Different positions
import { Button } from "@/components/ui/button";
import { RelativeTimeCard } from "@/components/ui/relative-time-card";
import { Clock } from "lucide-react";
 
export function RelativeTimeCardDemo() {
  const now = new Date();
  const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
  const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
  const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
  const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
 
  return (
    <div className="flex flex-col gap-6">
      <div className="flex flex-col gap-2">
        <span className="text-muted-foreground text-sm">Basic usage</span>
        <RelativeTimeCard date={fiveMinutesAgo} />
      </div>
      <div className="flex flex-col gap-2">
        <span className="text-muted-foreground text-sm">
          Different variants
        </span>
        <div className="flex items-center gap-4">
          <RelativeTimeCard date={oneHourAgo} variant="default" />
          <RelativeTimeCard date={oneHourAgo} variant="muted" />
          <RelativeTimeCard date={oneHourAgo} variant="ghost" />
        </div>
      </div>
      <div className="flex flex-col gap-2">
        <span className="text-muted-foreground text-sm">
          With time in the future
        </span>
        <div className="flex items-center gap-4">
          <RelativeTimeCard date={tomorrow} />
        </div>
      </div>
      <div className="flex flex-col gap-2">
        <span className="text-muted-foreground text-sm">
          Multiple timezones
        </span>
        <RelativeTimeCard
          date={oneDayAgo}
          timezones={["America/New_York", "Europe/London", "Asia/Tokyo"]}
        />
      </div>
      <div className="flex flex-col gap-2">
        <span className="text-muted-foreground text-sm">Custom trigger</span>
        <RelativeTimeCard date={now} asChild>
          <Button variant="outline" size="sm">
            <Clock />
            View time details
          </Button>
        </RelativeTimeCard>
      </div>
      <div className="flex flex-col gap-2">
        <span className="text-muted-foreground text-sm">
          Different positions
        </span>
        <div className="flex items-center gap-4">
          <RelativeTimeCard date={now} side="top" align="start">
            Top Start
          </RelativeTimeCard>
          <RelativeTimeCard date={now} side="right" align="center">
            Right Center
          </RelativeTimeCard>
          <RelativeTimeCard date={now} side="bottom" align="end">
            Bottom End
          </RelativeTimeCard>
        </div>
      </div>
    </div>
  );
}

Installation

CLI

npx shadcn@latest add "https://diceui.com/r/relative-time-card"
pnpm dlx shadcn@latest add "https://diceui.com/r/relative-time-card"
yarn dlx shadcn@latest add "https://diceui.com/r/relative-time-card"
bun x shadcn@latest add "https://diceui.com/r/relative-time-card"

Manual

Install the following dependencies:

npm install @radix-ui/react-hover-card
@radix-ui/react-slot
pnpm add @radix-ui/react-hover-card
@radix-ui/react-slot
yarn add @radix-ui/react-hover-card
@radix-ui/react-slot
bun add @radix-ui/react-hover-card
@radix-ui/react-slot

Copy and paste the following code into your project.

"use client";
 
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import type {
  HoverCardContentProps,
  HoverCardProps,
} from "@radix-ui/react-hover-card";
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
 
function pluralize(n: number, word: string) {
  return `${n} ${word}${n === 1 ? "" : "s"}`;
}
 
function formatRelativeTime(date: Date): string {
  const now = new Date();
  const diff = now.getTime() - date.getTime();
  const isInFuture = diff < 0;
  const absDiff = Math.abs(diff);
 
  const seconds = Math.floor(absDiff / 1000);
  const minutes = Math.floor(seconds / 60);
  const hours = Math.floor(minutes / 60);
  const days = Math.floor(hours / 24);
 
  if (seconds < 5) return "just now";
 
  if (isInFuture) {
    if (seconds < 60) return `in ${pluralize(seconds, "second")}`;
    if (minutes < 60) return `in ${pluralize(minutes, "minute")}`;
    if (hours < 24) return `in ${pluralize(hours, "hour")}`;
    if (days < 7) return `in ${pluralize(days, "day")}`;
    return date.toLocaleDateString();
  }
 
  if (seconds < 60) return `${pluralize(seconds, "second")} ago`;
  if (minutes < 60)
    return `${pluralize(minutes, "minute")} ${pluralize(seconds % 60, "second")} ago`;
  if (hours < 24) return `${pluralize(hours, "hour")} ago`;
  if (days < 7) return `${pluralize(days, "day")} ago`;
  return date.toLocaleDateString();
}
 
interface TimezoneCardProps extends React.ComponentPropsWithoutRef<"div"> {
  date: Date;
  timezone?: string;
}
 
function TimezoneCard(props: TimezoneCardProps) {
  const { date, timezone, ...cardProps } = props;
 
  const locale = React.useMemo(
    () => Intl.DateTimeFormat().resolvedOptions().locale,
    [],
  );
 
  const timezoneName = React.useMemo(
    () =>
      timezone ??
      new Intl.DateTimeFormat(locale, { timeZoneName: "shortOffset" })
        .formatToParts(date)
        .find((part) => part.type === "timeZoneName")?.value,
    [date, timezone, locale],
  );
 
  const { formattedDate, formattedTime } = React.useMemo(
    () => ({
      formattedDate: new Intl.DateTimeFormat(locale, {
        month: "long",
        day: "numeric",
        year: "numeric",
        timeZone: timezone,
      }).format(date),
      formattedTime: new Intl.DateTimeFormat(locale, {
        hour: "2-digit",
        minute: "2-digit",
        second: "2-digit",
        hour12: true,
        timeZone: timezone,
      }).format(date),
    }),
    [date, timezone, locale],
  );
 
  return (
    <div
      aria-label={`Time in ${timezoneName}: ${formattedDate} ${formattedTime}`}
      {...cardProps}
      className="flex items-center justify-between gap-2 text-muted-foreground text-sm"
    >
      <span className="w-fit rounded bg-accent px-1 font-medium text-xs">
        {timezoneName}
      </span>
      <div className="flex items-center gap-2">
        <time dateTime={date.toISOString()}>{formattedDate}</time>
        <time className="tabular-nums" dateTime={date.toISOString()}>
          {formattedTime}
        </time>
      </div>
    </div>
  );
}
 
const triggerVariants = cva(
  "inline-flex w-fit items-center justify-center text-foreground/70 text-sm transition-colors hover:text-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
  {
    variants: {
      variant: {
        default: "",
        muted: "text-foreground/50 hover:text-foreground/70",
        ghost: "hover:underline",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);
 
interface RelativeTimeCardProps
  extends React.ComponentPropsWithoutRef<"button">,
    HoverCardProps,
    Pick<
      HoverCardContentProps,
      | "align"
      | "side"
      | "alignOffset"
      | "sideOffset"
      | "avoidCollisions"
      | "collisionBoundary"
      | "collisionPadding"
      | "asChild"
    >,
    VariantProps<typeof triggerVariants> {
  date: Date | string | number;
  timezones?: string[];
  updateInterval?: number;
}
 
function RelativeTimeCard(props: RelativeTimeCardProps) {
  const {
    date: dateProp,
    variant,
    timezones = ["UTC"],
    open,
    defaultOpen,
    onOpenChange,
    openDelay = 500,
    closeDelay = 300,
    align,
    side,
    alignOffset,
    sideOffset,
    avoidCollisions,
    collisionBoundary,
    collisionPadding,
    updateInterval = 1000,
    asChild,
    children,
    className,
    ...triggerProps
  } = props;
 
  const date = React.useMemo(
    () => (dateProp instanceof Date ? dateProp : new Date(dateProp)),
    [dateProp],
  );
 
  const locale = React.useMemo(
    () => Intl.DateTimeFormat().resolvedOptions().locale,
    [],
  );
 
  const [formattedTime, setFormattedTime] = React.useState<string>(() =>
    date.toLocaleDateString(),
  );
 
  React.useEffect(() => {
    setFormattedTime(formatRelativeTime(date));
    const timer = setInterval(() => {
      setFormattedTime(formatRelativeTime(date));
    }, updateInterval);
 
    return () => clearInterval(timer);
  }, [date, updateInterval]);
 
  const TriggerPrimitive = asChild ? Slot : "button";
 
  return (
    <HoverCard
      open={open}
      defaultOpen={defaultOpen}
      onOpenChange={onOpenChange}
      openDelay={openDelay}
      closeDelay={closeDelay}
    >
      <HoverCardTrigger asChild>
        <TriggerPrimitive
          {...triggerProps}
          className={cn(triggerVariants({ variant, className }))}
        >
          {children ?? (
            <time dateTime={date.toISOString()} suppressHydrationWarning>
              {new Intl.DateTimeFormat(locale, {
                month: "short",
                day: "numeric",
                year: "numeric",
                hour: "2-digit",
                minute: "2-digit",
              }).format(date)}
            </time>
          )}
        </TriggerPrimitive>
      </HoverCardTrigger>
      <HoverCardContent
        side={side}
        align={align}
        sideOffset={sideOffset}
        alignOffset={alignOffset}
        avoidCollisions={avoidCollisions}
        collisionBoundary={collisionBoundary}
        collisionPadding={collisionPadding}
        className="flex w-full max-w-[420px] flex-col gap-2 p-3"
      >
        <time
          dateTime={date.toISOString()}
          className="text-muted-foreground text-sm"
        >
          {formattedTime}
        </time>
        <div role="list" className="flex flex-col gap-1">
          {timezones.map((timezone) => (
            <TimezoneCard
              key={timezone}
              role="listitem"
              date={date}
              timezone={timezone}
            />
          ))}
          <TimezoneCard role="listitem" date={date} />
        </div>
      </HoverCardContent>
    </HoverCard>
  );
}
 
export { RelativeTimeCard };

Examples

With Multiple Timezones

import { RelativeTimeCard } from "@/components/ui/relative-time-card";
 
export function RelativeTimeCardTimezonesDemo() {
  const now = new Date();
 
  return (
    <div className="flex flex-col gap-4">
      <RelativeTimeCard
        date={now}
        timezones={[
          "America/Los_Angeles",
          "America/New_York",
          "Europe/London",
          "Asia/Singapore",
          "Asia/Tokyo",
        ]}
      />
      <RelativeTimeCard
        date={now}
        timezones={[
          "America/Chicago",
          "Europe/Paris",
          "Asia/Dubai",
          "Australia/Sydney",
        ]}
      />
      <RelativeTimeCard date={now} timezones={["UTC"]} />
    </div>
  );
}

With Variants

Style variants
Custom styling
Hover card positions
Custom trigger
import { Button } from "@/components/ui/button";
import { RelativeTimeCard } from "@/components/ui/relative-time-card";
import { Clock } from "lucide-react";
 
export function RelativeTimeCardVariantsDemo() {
  const now = new Date();
 
  return (
    <div className="flex flex-col gap-6">
      <div className="flex flex-col gap-2">
        <span className="text-muted-foreground text-sm">Style variants</span>
        <div className="flex items-center gap-4">
          <RelativeTimeCard date={now} variant="default" />
          <RelativeTimeCard date={now} variant="muted" />
          <RelativeTimeCard date={now} variant="ghost" />
        </div>
      </div>
      <div className="flex flex-col gap-2">
        <span className="text-muted-foreground text-sm">Custom styling</span>
        <div className="flex items-center gap-4">
          <RelativeTimeCard
            date={now}
            className="text-blue-500 hover:text-blue-700"
          />
          <RelativeTimeCard
            date={now}
            className="font-semibold text-green-600 hover:text-green-800"
          />
          <RelativeTimeCard
            date={now}
            className="text-purple-500 italic hover:text-purple-700"
          />
        </div>
      </div>
      <div className="flex flex-col gap-2">
        <span className="text-muted-foreground text-sm">
          Hover card positions
        </span>
        <div className="flex items-center gap-4">
          <RelativeTimeCard date={now} side="top" align="start" sideOffset={10}>
            Top aligned
          </RelativeTimeCard>
          <RelativeTimeCard
            date={now}
            side="right"
            align="center"
            sideOffset={10}
          >
            Right aligned
          </RelativeTimeCard>
          <RelativeTimeCard
            date={now}
            side="bottom"
            align="end"
            sideOffset={10}
          >
            Bottom aligned
          </RelativeTimeCard>
        </div>
      </div>
      <div className="flex flex-col gap-2">
        <span className="text-muted-foreground text-sm">Custom trigger</span>
        <div className="flex items-center gap-4">
          <RelativeTimeCard date={now} asChild>
            <Button variant="outline" size="sm">
              <Clock />
              Time details
            </Button>
          </RelativeTimeCard>
          <RelativeTimeCard date={now}>
            <div className="flex items-center gap-2 text-emerald-600">
              <span className="i-lucide-calendar h-4 w-4" />
              <span>View date</span>
            </div>
          </RelativeTimeCard>
        </div>
      </div>
    </div>
  );
}

API Reference

RelativeTimeCard

The main component that displays relative time with hover functionality.

PropTypeDefault
asChild?
boolean
false
collisionPadding?
number | Partial<Record<"top" | "right" | "bottom" | "left", number>>
0
collisionBoundary?
Boundary | Boundary[]
[]
avoidCollisions?
boolean
true
alignOffset?
number
0
align?
"center" | "start" | "end"
"center"
sideOffset?
number
0
side?
"top" | "right" | "bottom" | "left"
"bottom"
closeDelay?
number
300
openDelay?
number
500
onOpenChange?
((open: boolean) => void)
-
defaultOpen?
boolean
false
open?
boolean
false
variant?
"default" | "subtle" | "ghost"
"default"
updateInterval?
number
1000
timezones?
string[]
["UTC"]
date
string | number | Date
-
Data AttributeValue
[data-state]"open" | "closed"

Accessibility

Keyboard Interactions

KeyDescription
TabOpens/closes the relative time card.
EnterOpens the relative time card if closed.

Credits