diff --git a/packages/circuit-ui/components/Timestamp/Timestamp.module.css b/packages/circuit-ui/components/Timestamp/Timestamp.module.css
new file mode 100644
index 0000000000..185a90b100
--- /dev/null
+++ b/packages/circuit-ui/components/Timestamp/Timestamp.module.css
@@ -0,0 +1,3 @@
+.base {
+ font-variant-numeric: tabular-nums;
+}
diff --git a/packages/circuit-ui/components/Timestamp/Timestamp.spec.tsx b/packages/circuit-ui/components/Timestamp/Timestamp.spec.tsx
new file mode 100644
index 0000000000..851ec21eca
--- /dev/null
+++ b/packages/circuit-ui/components/Timestamp/Timestamp.spec.tsx
@@ -0,0 +1,137 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import MockDate from 'mockdate';
+import { createRef } from 'react';
+
+import { render, screen, axe, act } from '../../util/test-utils.js';
+
+import { Timestamp } from './Timestamp.js';
+
+describe('Calendar', () => {
+ beforeEach(() => {
+ MockDate.set('2020-01-01T01:00+01:00');
+ });
+
+ const baseProps = {
+ datetime: '2020-01-01T00:00+01:00[Europe/Berlin]',
+ };
+
+ it('should merge a custom class name with the default ones', () => {
+ const className = 'foo';
+ render();
+ const element = screen.getByRole('time');
+ expect(element?.className).toContain(className);
+ });
+
+ it('should forward a ref to the outer element', () => {
+ const ref = createRef();
+ render();
+ const element = screen.getByRole('time');
+ expect(ref.current).toBe(element);
+ });
+
+ it('should have a valid `datetime` attribute', () => {
+ render();
+ const element = screen.getByRole('time');
+ expect(element).toHaveAttribute('datetime', '2020-01-01T00:00:00+01:00');
+ });
+
+ describe('absolute variant', () => {
+ it('should display a narrow human-readable date time', () => {
+ render(
+ ,
+ );
+ const element = screen.getByRole('time');
+ expect(element).toHaveTextContent('1/1/20');
+ });
+
+ it('should display a short human-readable date time', () => {
+ render(
+ ,
+ );
+ const element = screen.getByRole('time');
+ expect(element).toHaveTextContent('Jan 1, 2020');
+ });
+
+ it('should display a long human-readable date time', () => {
+ render(
+ ,
+ );
+ const element = screen.getByRole('time');
+ expect(element).toHaveTextContent('January 1, 2020 at 12:00 AM');
+ });
+ });
+
+ describe('relative variant', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should display a narrow human-readable date time', () => {
+ render(
+ ,
+ );
+ const element = screen.getByRole('time');
+ expect(element).toHaveTextContent('1h ago');
+ });
+
+ it('should display a short human-readable date time', () => {
+ render(
+ ,
+ );
+ const element = screen.getByRole('time');
+ expect(element).toHaveTextContent('1 hr. ago');
+ });
+
+ it('should display a long human-readable date time', () => {
+ render(
+ ,
+ );
+ const element = screen.getByRole('time');
+ expect(element).toHaveTextContent('1 hour ago');
+ });
+
+ it('should update the time after an interval', () => {
+ render(
+ ,
+ );
+
+ const element = screen.getByRole('time');
+ expect(element).toHaveTextContent('in 1m');
+
+ act(() => {
+ vi.advanceTimersByTime(10 * 1000);
+ });
+
+ expect(element).toHaveTextContent('in 50s');
+ });
+ });
+
+ it('should meet accessibility guidelines', async () => {
+ const { container } = render();
+ const actual = await axe(container);
+ expect(actual).toHaveNoViolations();
+ });
+});
diff --git a/packages/circuit-ui/components/Timestamp/Timestamp.stories.tsx b/packages/circuit-ui/components/Timestamp/Timestamp.stories.tsx
new file mode 100644
index 0000000000..908dc18944
--- /dev/null
+++ b/packages/circuit-ui/components/Timestamp/Timestamp.stories.tsx
@@ -0,0 +1,83 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Temporal } from 'temporal-polyfill';
+
+import { Stack } from '../../../../.storybook/components/Stack.js';
+
+import { Timestamp, type TimestampProps } from './Timestamp.js';
+
+export default {
+ title: 'Components/Timestamp',
+ component: Timestamp,
+};
+
+const datetimes = [
+ Temporal.Now.zonedDateTimeISO().add({ months: 4 }),
+ Temporal.Now.zonedDateTimeISO().add({ seconds: 64 }),
+ Temporal.Now.zonedDateTimeISO().subtract({ minutes: 7 }),
+ Temporal.Now.zonedDateTimeISO().subtract({ days: 1 }),
+ Temporal.Now.zonedDateTimeISO().subtract({ weeks: 5 }),
+];
+
+const locales = ['en-US', 'de-DE', 'pt-BR'];
+
+export const Base = (args: TimestampProps) => ;
+
+Base.args = {
+ datetime: datetimes[2].toString(),
+};
+
+export const Relative = (args: TimestampProps) => (
+
+ {locales.map((locale) => (
+
+ {datetimes.map((datetime) => (
+
+ ))}
+
+ ))}
+
+);
+
+Relative.args = {
+ variant: 'relative',
+};
+
+export const Absolute = (args: TimestampProps) => (
+
+ {locales.map((locale) => (
+
+ {datetimes.map((datetime) => (
+
+ ))}
+
+ ))}
+
+);
+
+Absolute.args = {
+ variant: 'absolute',
+};
diff --git a/packages/circuit-ui/components/Timestamp/Timestamp.tsx b/packages/circuit-ui/components/Timestamp/Timestamp.tsx
new file mode 100644
index 0000000000..4783693537
--- /dev/null
+++ b/packages/circuit-ui/components/Timestamp/Timestamp.tsx
@@ -0,0 +1,121 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+'use client';
+
+import { forwardRef, useEffect, useState, type HTMLAttributes } from 'react';
+import { Temporal } from 'temporal-polyfill';
+
+import type { Locale } from '../../util/i18n.js';
+import { clsx } from '../../styles/clsx.js';
+
+import { getInitialState, getState } from './TimestampService.js';
+import classes from './Timestamp.module.css';
+
+export interface TimestampProps extends HTMLAttributes {
+ /**
+ * TODO: Write description
+ */
+ datetime: string;
+ /**
+ * TODO: Write description
+ *
+ * @default 'auto'
+ */
+ variant?: 'auto' | 'relative' | 'absolute';
+ /**
+ * TODO: Write description
+ *
+ * @default 'P1M' // 1 month
+ */
+ threshold?: string;
+ /**
+ * TODO: Write description
+ *
+ * @default 'short'
+ */
+ formatStyle?: 'long' | 'short' | 'narrow';
+ /**
+ * One or more [IETF BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag)
+ * locale identifiers such as `'de-DE'` or `['GB', 'en-US']`.
+ * When passing an array, the first supported locale is used.
+ * Defaults to `navigator.language` in supported environments.
+ */
+ locale?: Locale;
+}
+
+// TODO: initial, server-rendered label should include timezone and respect format style
+
+/**
+ * TODO:
+ */
+export const Timestamp = forwardRef(
+ (
+ {
+ datetime,
+ variant = 'auto',
+ formatStyle = 'short',
+ threshold = 'P1M',
+ locale,
+ className,
+ ...props
+ },
+ ref,
+ ) => {
+ const zonedDateTime = Temporal.ZonedDateTime.from(datetime);
+ const [state, setState] = useState(
+ getInitialState(datetime, locale, formatStyle),
+ );
+
+ // Update state on props change
+ useEffect(() => {
+ setState(getState(datetime, locale, formatStyle, variant, threshold));
+ }, [datetime, variant, formatStyle, locale, threshold]);
+
+ // Update state in regular intervals for relative times
+ useEffect(() => {
+ if (!state.interval) {
+ return undefined;
+ }
+
+ const timer = setInterval(() => {
+ setState(getState(datetime, locale, formatStyle, variant, threshold));
+ }, state.interval);
+
+ return () => {
+ clearInterval(timer);
+ };
+ }, [state.interval, datetime, variant, formatStyle, locale, threshold]);
+
+ return (
+
+ );
+ },
+);
diff --git a/packages/circuit-ui/components/Timestamp/TimestampService.ts b/packages/circuit-ui/components/Timestamp/TimestampService.ts
new file mode 100644
index 0000000000..01c1e803bd
--- /dev/null
+++ b/packages/circuit-ui/components/Timestamp/TimestampService.ts
@@ -0,0 +1,140 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Temporal } from 'temporal-polyfill';
+import {
+ formatDateTime,
+ formatRelativeTime,
+ isRelativeTimeFormatSupported,
+} from '@sumup-oss/intl';
+
+import type { Locale } from '../../util/i18n.js';
+
+export const DATE_TIME_STYLE_MAP = {
+ long: { dateStyle: 'long', timeStyle: 'short' },
+ short: { dateStyle: 'medium' },
+ narrow: { dateStyle: 'short' },
+} satisfies Record;
+
+const UNITS = [
+ {
+ name: 'years',
+ duration: Temporal.Duration.from('P1Y'),
+ interval: 300000,
+ },
+ {
+ name: 'months',
+ duration: Temporal.Duration.from('P1M'),
+ interval: 300000,
+ },
+ {
+ name: 'weeks',
+ duration: Temporal.Duration.from('P1W'),
+ interval: 300000,
+ },
+ {
+ name: 'days',
+ duration: Temporal.Duration.from('P1D'),
+ interval: 300000,
+ },
+ {
+ name: 'hours',
+ duration: Temporal.Duration.from('PT1H'),
+ interval: 60000,
+ },
+ {
+ name: 'minutes',
+ duration: Temporal.Duration.from('PT1M'),
+ interval: 5000,
+ },
+ {
+ name: 'seconds',
+ duration: Temporal.Duration.from('PT1S'),
+ interval: 1000,
+ },
+] satisfies {
+ name: Temporal.SmallestUnit;
+ duration: Temporal.Duration;
+ interval: number; // in milliseconds
+}[];
+
+export type State = {
+ label: string;
+ interval: number | null;
+};
+
+export function getInitialState(
+ datetime: string,
+ locale: Locale | undefined,
+ formatStyle: 'long' | 'short' | 'narrow',
+): State {
+ const zonedDateTime = Temporal.ZonedDateTime.from(datetime);
+ const options = DATE_TIME_STYLE_MAP[formatStyle];
+ return {
+ label: formatDateTime(zonedDateTime.toPlainDateTime(), locale, options),
+ interval: null,
+ };
+}
+
+export function getState(
+ datetime: string,
+ locale: Locale | undefined,
+ formatStyle: 'long' | 'short' | 'narrow',
+ variant: 'auto' | 'relative' | 'absolute',
+ threshold: string,
+): State {
+ const zonedDateTime = Temporal.ZonedDateTime.from(datetime);
+ const now = Temporal.Now.zonedDateTimeISO();
+ const duration = zonedDateTime.since(now);
+
+ const isBeyondThreshold =
+ Temporal.Duration.compare(
+ duration.abs(),
+ Temporal.Duration.from(threshold),
+ { relativeTo: now },
+ ) > 0;
+
+ if (
+ variant === 'absolute' ||
+ (variant === 'auto' && isBeyondThreshold) ||
+ !isRelativeTimeFormatSupported
+ ) {
+ const options = DATE_TIME_STYLE_MAP[formatStyle];
+ return {
+ label: formatDateTime(zonedDateTime.toPlainDateTime(), locale, options),
+ interval: null,
+ };
+ }
+
+ const bestUnitIndex = UNITS.findIndex(
+ (unit) =>
+ Temporal.Duration.compare(duration.abs(), unit.duration, {
+ relativeTo: now,
+ }) >= 0,
+ );
+ // Use 'seconds' when no unit was found, i.e. when the duration is less than a second
+ const unitIndex = bestUnitIndex >= 0 ? bestUnitIndex : UNITS.length - 1;
+ const unit = UNITS[unitIndex];
+ const value = duration.round({
+ smallestUnit: unit.name,
+ relativeTo: now,
+ })[unit.name];
+ const options = { style: formatStyle };
+
+ return {
+ label: formatRelativeTime(value, unit.name, locale, options),
+ interval: unit.interval,
+ };
+}
diff --git a/packages/circuit-ui/components/Timestamp/index.ts b/packages/circuit-ui/components/Timestamp/index.ts
new file mode 100644
index 0000000000..9bad3b62a2
--- /dev/null
+++ b/packages/circuit-ui/components/Timestamp/index.ts
@@ -0,0 +1,18 @@
+/**
+ * Copyright 2024, SumUp Ltd.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { Timestamp } from './Timestamp.js';
+
+export type { TimestampProps } from './Timestamp.js';
diff --git a/packages/circuit-ui/experimental.ts b/packages/circuit-ui/experimental.ts
index d88415a6bb..dfe91e41b7 100644
--- a/packages/circuit-ui/experimental.ts
+++ b/packages/circuit-ui/experimental.ts
@@ -13,3 +13,7 @@
* limitations under the License.
*/
+export {
+ Timestamp,
+ type TimestampProps,
+} from './components/Timestamp/index.js';