---
title: Countdown
description: A live countdown timer that displays the remaining time to a target date as animated flip-card digits, broken into days, hours, minutes, and seconds.
---

```tsx
'use client';

import { Countdown } from '@/ui/Countdown';

const labels = {
  days: 'days',
  hours: 'hours',
  minutes: 'minutes',
  seconds: 'seconds',
};

const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);

const nextWeek = new Date();
nextWeek.setDate(nextWeek.getDate() + 7);

export function CountdownDemo() {
  return (
    <div className="flex flex-col gap-8">
      <Countdown countdown={{ to: tomorrow.toISOString(), labels }} theme="saffron" />
      <div className="bg-ultramarine p-6 rounded-16">
        <Countdown
          countdown={{ to: nextWeek.toISOString(), labels }}
          theme="white"
          className="text-white"
        />
      </div>
    </div>
  );
}
```

## Installation

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/countdown.json
```

Or manually, copy the following code into `/ui/Countdown.tsx`:

```tsx
'use client';
import type { ComponentProps } from 'react';
import { useEffect, useState } from 'react';
import clsx from 'clsx';

import { FlipCard } from './FlipCard';
import type { Theme } from './types/Theme';

export interface CountdownLabels {
  days: string;
  hours: string;
  minutes: string;
  seconds: string;
}

export interface CountdownProps extends Omit<ComponentProps<'div'>, 'children'> {
  countdown: {
    labels: CountdownLabels;
    to: string;
  };
  theme?: Theme;
}

const ONE_SEC = 1000;
const ONE_MIN = ONE_SEC * 60;
const ONE_HOUR = ONE_MIN * 60;
const ONE_DAY = ONE_HOUR * 24;

const getDay = (distance: number) => Math.max(Math.floor(distance / ONE_DAY), 0);
const getHour = (distance: number) => Math.max(Math.floor((distance % ONE_DAY) / ONE_HOUR), 0);
const getMinute = (distance: number) => Math.max(Math.floor((distance % ONE_HOUR) / ONE_MIN), 0);
const getSecond = (distance: number) => Math.max(Math.floor((distance % ONE_MIN) / ONE_SEC), 0);

export function Countdown({ className, countdown, theme = 'white', ...props }: CountdownProps) {
  const { to, labels } = countdown;
  const [days, hours, minutes, seconds] = useCountdown(to);

  const structure: [number, string][] = [
    [days, labels.days],
    [hours, labels.hours],
    [minutes, labels.minutes],
    [seconds, labels.seconds],
  ];

  return (
    <div
      data-name="Countdown"
      role="timer"
      {...props}
      dir="ltr"
      className={clsx('flex gap-x-4', className)}
    >
      {structure.map(([division, label], index) => {
        const areAllPreviousZero = structure.slice(0, index).every(([d]) => d === 0);
        const divisionTheme = division === 0 && areAllPreviousZero ? 'black' : theme;

        return (
          <div className="space-y-4" key={label}>
            <div className="flex flex-row items-center gap-x-2 px-2">
              {division
                .toString()
                .padStart(2, '0')
                .split('')
                .map((digit, i) => (
                  <FlipCard key={i} theme={divisionTheme} value={parseInt(digit)} />
                ))}
            </div>
            <span className="text-b5 block max-w-58 truncate text-center">{label}</span>
          </div>
        );
      })}
    </div>
  );
}

function useCountdown(to: string): [number, number, number, number] {
  const toMs = new Date(to).getTime();
  const distance = toMs - Date.now();
  const [days, setDays] = useState(() => getDay(distance));
  const [hours, setHours] = useState(() => getHour(distance));
  const [minutes, setMinutes] = useState(() => getMinute(distance));
  const [seconds, setSeconds] = useState(() => getSecond(distance));

  useEffect(() => {
    const interval = setInterval(() => {
      const dist = toMs - Date.now();
      if (dist < 0) {
        clearInterval(interval);
        return;
      }
      setDays(getDay(dist));
      setHours(getHour(dist));
      setMinutes(getMinute(dist));
      setSeconds(getSecond(dist));
    }, 1000);

    return () => clearInterval(interval);
  }, [toMs]);

  return [days, hours, minutes, seconds];
}
```

## Usage

```tsx showLineNumbers
'use client';

import { Countdown } from '@/ui/Countdown';

export function Component() {
  const target = new Date();
  target.setDate(target.getDate() + 1);

  return (
    <Countdown
      countdown={{
        to: target.toISOString(),
        labels: {
          days: 'days',
          hours: 'hours',
          minutes: 'minutes',
          seconds: 'seconds',
        },
      }}
      theme="saffron"
    />
  );
}
```

## API Reference

### Countdown

| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| `countdown` | `{ to: string; labels: CountdownLabels }` | required | Target date (ISO string) and display labels for each unit |
| `theme` | `Theme` | `"white"` | Color theme applied to the flip-card digits |
| `className` | `string` | `undefined` | Additional CSS classes for the outer wrapper |

### CountdownLabels

| Prop | Type | Description |
| ---- | ---- | ----------- |
| `days` | `string` | Label shown below the days digits |
| `hours` | `string` | Label shown below the hours digits |
| `minutes` | `string` | Label shown below the minutes digits |
| `seconds` | `string` | Label shown below the seconds digits |

## Notes

- **Flip animation**: Each digit transitions with a 3D flip effect when its value changes, using the `animate-tick` / `animate-tock` keyframes defined in the trident-ui CSS.
- **Leading zero dimming**: Units whose value is 0 and whose higher-order units are also 0 are rendered with the `black` theme to indicate they are inactive.
- **Direction**: The component always renders `dir="ltr"` regardless of the document direction, so digit order stays consistent.
- **Client component**: Uses `setInterval` via `useEffect` and must be rendered in a client context (`'use client'`).
- **Accessibility**: The wrapper has `role="timer"` so assistive technologies announce it as a timer landmark.
- **Stopping**: The interval is cleared automatically when the target date is reached; no further re-renders occur after expiry.
