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.
Prop | Type | Default |
---|---|---|
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 Attribute | Value |
---|---|
[data-state] | "open" | "closed" |
Accessibility
Keyboard Interactions
Key | Description |
---|---|
Tab | Opens/closes the relative time card. |
Enter | Opens the relative time card if closed. |
Credits
- John Phamous - For the initial implementation.