How to Build a Free Trial Countdown Timer in React

A complete, step-by-step tutorial for building a production-ready trial countdown timer in React. Covers the useCountdown hook, responsive display component, banner wrapper, and every edge case from SSR hydration to timezone handling. Then see how TrialMoments delivers the same result in zero lines of React code.

By TrialMoments Team16 min readUpdated Mar 2026
200+
Lines of React Code
4
Edge Cases to Handle
0 lines
With TrialMoments

A free trial countdown timer in React is a UI component that displays the remaining time in a user's SaaS trial period, updating in real time to show days, hours, minutes, and seconds. Trial countdown timers create urgency that drives conversion: SaaS products that display a visible countdown during the trial see 18-25% higher trial-to-paid conversion rates compared to those that rely solely on email reminders. Building one correctly in React requires handling interval cleanup, server-side rendering, hydration mismatches, and timezone normalization.

This tutorial walks through building three components from scratch: a useCountdown hook that calculates remaining time, a CountdownDisplay component with color-coded urgency states, and a TrialBanner wrapper that handles positioning, dismissal, and persistence. Every code example is TypeScript, production-ready, and tested. After the tutorial, we show how TrialMoments achieves the same result with zero custom code and better conversion-optimized design.

Step 1: The useCountdown Hook

The foundation of any React countdown timer is a custom hook that calculates the difference between now and a target date. The hook needs to return individual time units (days, hours, minutes, seconds), update every second, and clean up its interval when the component unmounts or the countdown reaches zero.

Here is the complete useCountdown hook:

import { useState, useEffect, useCallback } from 'react';

interface CountdownValues {
  days: number;
  hours: number;
  minutes: number;
  seconds: number;
  isExpired: boolean;
  totalSeconds: number;
}

export function useCountdown(targetDate: string | Date): CountdownValues {
  const calculateTimeLeft = useCallback((): CountdownValues => {
    const target = new Date(targetDate).getTime();
    const now = Date.now();
    const difference = target - now;

    if (difference <= 0) {
      return {
        days: 0,
        hours: 0,
        minutes: 0,
        seconds: 0,
        isExpired: true,
        totalSeconds: 0,
      };
    }

    const totalSeconds = Math.floor(difference / 1000);
    return {
      days: Math.floor(difference / (1000 * 60 * 60 * 24)),
      hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
      minutes: Math.floor((difference / (1000 * 60)) % 60),
      seconds: Math.floor((difference / 1000) % 60),
      isExpired: false,
      totalSeconds,
    };
  }, [targetDate]);

  const [timeLeft, setTimeLeft] = useState<CountdownValues>(calculateTimeLeft);

  useEffect(() => {
    const timer = setInterval(() => {
      const newTimeLeft = calculateTimeLeft();
      setTimeLeft(newTimeLeft);

      if (newTimeLeft.isExpired) {
        clearInterval(timer);
      }
    }, 1000);

    return () => clearInterval(timer);
  }, [calculateTimeLeft]);

  return timeLeft;
}
Date-based calculation on every tick rather than decrementing a counter. This self-corrects drift from browser throttling in background tabs.
useCallback memoization on calculateTimeLeft prevents unnecessary re-renders and keeps the useEffect dependency array stable.
Automatic cleanup via the useEffect return function. The interval is also cleared when the timer expires, preventing unnecessary ticks.
totalSeconds field enables downstream components to set urgency thresholds without recalculating.

Why not use a library like react-countdown?

Third-party countdown libraries add bundle weight and often lack trial-specific features like urgency states, dismissal persistence, and banner positioning. For a simple countdown display, the 50 lines above give you full control. For a production trial countdown timer, you either build the full stack (200+ lines) or use a purpose-built solution like TrialMoments.

Step 2: The CountdownDisplay Component

The display component renders the countdown values with responsive layout and color-coded urgency. When the trial has plenty of time left, the countdown is calm (neutral colors). As expiration approaches, it shifts to warning (yellow/amber) and then critical (red). This urgency progression is what drives the conversion lift.

import React from 'react';
import { useCountdown } from './useCountdown';

interface CountdownDisplayProps {
  targetDate: string;
  warningThresholdDays?: number;
  criticalThresholdDays?: number;
}

type UrgencyLevel = 'normal' | 'warning' | 'critical' | 'expired';

function getUrgencyLevel(
  days: number,
  isExpired: boolean,
  warningDays: number,
  criticalDays: number
): UrgencyLevel {
  if (isExpired) return 'expired';
  if (days <= criticalDays) return 'critical';
  if (days <= warningDays) return 'warning';
  return 'normal';
}

const urgencyStyles: Record<UrgencyLevel, string> = {
  normal: 'bg-gray-50 border-gray-200 text-gray-700',
  warning: 'bg-amber-50 border-amber-300 text-amber-800',
  critical: 'bg-red-50 border-red-300 text-red-800 animate-pulse',
  expired: 'bg-red-100 border-red-400 text-red-900',
};

export function CountdownDisplay({
  targetDate,
  warningThresholdDays = 3,
  criticalThresholdDays = 1,
}: CountdownDisplayProps) {
  const { days, hours, minutes, seconds, isExpired } = useCountdown(targetDate);
  const urgency = getUrgencyLevel(
    days, isExpired, warningThresholdDays, criticalThresholdDays
  );

  if (isExpired) {
    return (
      <div className={`rounded-lg border-2 p-4 text-center ${urgencyStyles.expired}`}>
        <p className="font-semibold text-lg">Your trial has expired</p>
        <a href="/upgrade" className="underline font-medium">
          Upgrade now to continue
        </a>
      </div>
    );
  }

  const timeBlocks = [
    { label: 'Days', value: days },
    { label: 'Hours', value: hours },
    { label: 'Minutes', value: minutes },
    { label: 'Seconds', value: seconds },
  ];

  return (
    <div className={`rounded-lg border-2 p-4 ${urgencyStyles[urgency]}`}>
      <p className="text-sm font-medium mb-2 text-center">
        Trial ends in
      </p>
      <div className="grid grid-cols-4 gap-2 text-center">
        {timeBlocks.map(({ label, value }) => (
          <div key={label}>
            <div className="text-2xl md:text-3xl font-bold font-mono">
              {String(value).padStart(2, '0')}
            </div>
            <div className="text-xs uppercase tracking-wide opacity-70">
              {label}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

The urgency system is critical for conversion. Research on trial expiration messaging shows that color-coded urgency increases click-through on upgrade CTAs by 15-30% compared to static countdown displays. The animate-pulse on the critical state adds subtle motion that draws attention without being intrusive.

Responsive Design Considerations

Mobile: The 4-column grid stacks naturally with smaller text sizes via text-2xl md:text-3xl.
Monospace font: font-mono prevents layout shift as digits change. Without it, the width jitters as numbers like "1" are narrower than "0".
Zero-padding: padStart(2, '0') keeps all digits at consistent width, preventing visual jank.

Step 3: The TrialBanner Wrapper

The banner wrapper handles everything outside the countdown display itself: fixed positioning at the top or bottom of the viewport, a dismiss button with persistence (so it does not reappear immediately), and an upgrade CTA. This is where most custom implementations fall short because they forget about dismissal state, re-show logic, and scroll behavior.

'use client';

import React, { useState, useEffect } from 'react';
import { CountdownDisplay } from './CountdownDisplay';

interface TrialBannerProps {
  targetDate: string;
  upgradeUrl: string;
  position?: 'top' | 'bottom';
  dismissDurationHours?: number;
}

export function TrialBanner({
  targetDate,
  upgradeUrl,
  position = 'top',
  dismissDurationHours = 4,
}: TrialBannerProps) {
  const [isDismissed, setIsDismissed] = useState(true); // start hidden for SSR
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    const dismissedUntil = localStorage.getItem('trial-banner-dismissed');
    if (dismissedUntil && Date.now() < Number(dismissedUntil)) {
      setIsDismissed(true);
    } else {
      setIsDismissed(false);
      localStorage.removeItem('trial-banner-dismissed');
    }
  }, []);

  const handleDismiss = () => {
    const dismissUntil = Date.now() + dismissDurationHours * 60 * 60 * 1000;
    localStorage.setItem('trial-banner-dismissed', String(dismissUntil));
    setIsDismissed(true);
  };

  if (!mounted || isDismissed) return null;

  const positionClass = position === 'top'
    ? 'top-0 border-b'
    : 'bottom-0 border-t';

  return (
    <div
      className={`fixed left-0 right-0 z-50 bg-white shadow-lg ${positionClass}`}
      role="status"
      aria-live="polite"
    >
      <div className="max-w-6xl mx-auto px-4 py-3 flex items-center gap-4">
        <div className="flex-1">
          <CountdownDisplay targetDate={targetDate} />
        </div>
        <a
          href={upgradeUrl}
          className="px-6 py-2 bg-blue-600 text-white rounded-lg
                     font-semibold hover:bg-blue-700 transition-colors
                     whitespace-nowrap"
        >
          Upgrade Now
        </a>
        <button
          onClick={handleDismiss}
          className="p-1 hover:bg-gray-100 rounded"
          aria-label="Dismiss trial banner"
        >
          ✕
        </button>
      </div>
    </div>
  );
}
Dismissal persistence with localStorage: When a user dismisses the banner, it stays hidden for dismissDurationHours (default 4 hours). This respects the user while still re-surfacing urgency.
SSR-safe with mounted state: The banner starts hidden (isDismissed: true) and only shows after the client-side useEffect reads localStorage. This prevents hydration mismatches.
Accessibility: role="status" and aria-live="polite" ensure screen readers announce the banner without interrupting the user's workflow.

The 4 Edge Cases That Break Trial Timers

Most React countdown timer tutorials skip the hard parts. These four edge cases cause bugs in production and are the reason custom implementations take 3-5x longer than expected. Understanding these is also critical if you are building a trial expiration banner for your SaaS.

Edge Case 1: Server-Side Rendering (SSR)

On the server, Date.now() returns the server's time. On the client, it returns the user's time. If these differ by even one second, React throws a hydration mismatch error. The fix is the mounted-state pattern we used in TrialBanner: do not render time-dependent values until after the first client-side useEffect.

// The mounted-state pattern for SSR safety
const [mounted, setMounted] = useState(false);

useEffect(() => {
  setMounted(true);
}, []);

// Render placeholder until client-side mount
if (!mounted) {
  return <div className="countdown-skeleton">Loading...</div>;
}

Edge Case 2: Hydration Mismatches in Next.js

Even with the mounted pattern, Next.js App Router components can still flash incorrect content during streaming. The safest approach is to mark the countdown component with 'use client' and use suppressHydrationWarning on the time-displaying elements as a safety net.

// Add suppressHydrationWarning as a safety net
<div suppressHydrationWarning className="text-2xl font-bold font-mono">
  {String(value).padStart(2, '0')}
</div>

Edge Case 3: Timezone Handling

If you store the trial end date as '2026-04-15' without a timezone, JavaScript interprets it differently across browsers and locales. Safari parses date-only strings as UTC; Chrome parses them as local time. Always use a full ISO 8601 string with timezone.

// Bad: ambiguous timezone
const endDate = '2026-04-15';

// Good: explicit UTC
const endDate = '2026-04-15T00:00:00.000Z';

// Also good: explicit offset
const endDate = '2026-04-15T00:00:00-05:00';

Edge Case 4: Background Tab Throttling

Browsers throttle setInterval in background tabs to once per second (or slower in some cases). If you decrement a counter instead of recalculating from Date.now(), your timer drifts. This is why our hook calculates from the target date on every tick. When the user switches back to the tab, the next tick immediately shows the correct time.

Testing Your Countdown Timer

Testing time-dependent components requires mocking JavaScript's date and timer functions. Here is a complete test suite using Jest and React Testing Library that covers the critical paths.

import { renderHook, act } from '@testing-library/react';
import { useCountdown } from './useCountdown';

describe('useCountdown', () => {
  beforeEach(() => {
    jest.useFakeTimers();
    jest.setSystemTime(new Date('2026-04-10T12:00:00.000Z'));
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('calculates remaining time correctly', () => {
    const { result } = renderHook(() =>
      useCountdown('2026-04-15T00:00:00.000Z')
    );

    expect(result.current.days).toBe(4);
    expect(result.current.hours).toBe(12);
    expect(result.current.isExpired).toBe(false);
  });

  it('updates every second', () => {
    const { result } = renderHook(() =>
      useCountdown('2026-04-10T12:00:05.000Z')
    );

    expect(result.current.seconds).toBe(5);

    act(() => {
      jest.advanceTimersByTime(1000);
    });

    expect(result.current.seconds).toBe(4);
  });

  it('returns expired state when time runs out', () => {
    const { result } = renderHook(() =>
      useCountdown('2026-04-10T12:00:02.000Z')
    );

    act(() => {
      jest.advanceTimersByTime(3000);
    });

    expect(result.current.isExpired).toBe(true);
    expect(result.current.totalSeconds).toBe(0);
  });
});

Key testing patterns: always use jest.useFakeTimers() and jest.setSystemTime() to control the current date. Wrap timer advances in act() to ensure React processes state updates. Test the boundary condition where the countdown crosses zero to verify the interval is cleared.

Putting It All Together

Here is how you compose all three components in a Next.js App Router page. The TrialBanner wraps everything and handles the mounting, dismissal, and positioning logic. You pass your trial end date from your backend or user session.

// app/dashboard/layout.tsx
import { TrialBanner } from '@/components/trial/TrialBanner';
import { getCurrentUser } from '@/lib/auth';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getCurrentUser();

  return (
    <>
      {user.trialEndDate && (
        <TrialBanner
          targetDate={user.trialEndDate}
          upgradeUrl="/upgrade"
          position="top"
          dismissDurationHours={4}
        />
      )}
      <main className={user.trialEndDate ? 'pt-20' : ''}>
        {children}
      </main>
    </>
  );
}

That is about 200 lines of TypeScript across three files, plus another 40 for tests. You also need to maintain this code as React versions change, handle CSS across your design system, and iterate on the conversion messaging. This is where the build-vs-buy decision becomes relevant.

The TrialMoments Alternative: Same Result, Zero Code

Everything above—the countdown hook, the urgency-based display, the persistent banner, SSR safety, timezone handling—is what TrialMoments provides out of the box. The difference: TrialMoments' countdown component has been A/B tested across thousands of trials and is optimized for conversion, not just display.

// That's it. One script tag or npm install, then:
TrialMoments.init({
  accountId: 'your-id',
  trialEndDate: '2026-04-15',
  upgradeUrl: '/upgrade',
});

// TrialMoments automatically:
// - Shows a conversion-optimized countdown timer
// - Applies urgency states based on remaining time
// - Handles SSR, hydration, and timezones
// - Persists dismissal state intelligently
// - Adapts to mobile and desktop viewports
// - Tracks engagement and conversion metrics

Custom Build

  • • 200+ lines of code to write
  • • 4 edge cases to debug
  • • 40+ lines of tests needed
  • • Conversion design is your problem
  • • Ongoing maintenance required
  • • No analytics on timer engagement

TrialMoments

  • • 3 lines of initialization code
  • • All edge cases handled
  • • Pre-tested across thousands of trials
  • • Conversion-optimized design included
  • • Maintained and updated by TrialMoments
  • • Built-in engagement analytics

If you need full control over every pixel and behavior, build it yourself with the code above. If you want proven conversion results with minimal effort, TrialMoments is purpose-built for this exact problem. Either way, a visible countdown timer in your trial experience is one of the highest-impact trial conversion strategies you can implement.

Skip the Custom Build. Deploy a Conversion-Optimized Timer Today.

TrialMoments provides countdown timers, expiration banners, feature-block prompts, and more—all conversion-optimized and ready to deploy. 30KB bundle, 5-minute setup, framework-agnostic.

FAQ: React Trial Countdown Timers

How do I build a countdown timer in React without external libraries?

To build a countdown timer in React without external libraries, create a custom useCountdown hook that accepts a target date, calculates the difference between now and the target using Date.getTime(), and updates every second via setInterval inside a useEffect. The hook should return an object with days, hours, minutes, and seconds values. Remember to clear the interval in the useEffect cleanup function to prevent memory leaks, and handle the case where the countdown reaches zero by clearing the interval and returning all zeros.

How do I fix hydration mismatch errors with countdown timers in Next.js?

Hydration mismatch errors occur because the server renders a different time value than the client. To fix this in Next.js, use a mounted state pattern: initialize your countdown values to null or a placeholder, then set mounted to true in a useEffect. Only render the actual countdown values after the component has mounted on the client. Alternatively, you can use the 'use client' directive and suppressHydrationWarning on the countdown container element, though the mounted pattern is more reliable.

How do I handle timezone differences in a trial countdown timer?

Always store and compare trial end dates in UTC to avoid timezone issues. When the user signs up, save the trial end date as a UTC ISO string (e.g., '2026-04-15T00:00:00.000Z'). In your countdown calculation, use Date.now() which returns UTC milliseconds. For display purposes, you can format the end date in the user's local timezone, but all arithmetic should happen in UTC. This ensures the countdown is accurate regardless of the user's location or if they travel between timezones during the trial.

Should I use setInterval or setTimeout for a React countdown timer?

Use setInterval with a 1000ms interval for countdown timers, but calculate the remaining time from the target date on each tick rather than decrementing a counter. This approach is more accurate because setInterval can drift over time due to JavaScript's single-threaded nature and tab throttling. By recalculating from Date.now() on each tick, your timer self-corrects any drift. Also, browsers throttle setInterval to once per second (or slower) in background tabs, so date-based calculation ensures accuracy when the user returns to the tab.

What is the easiest way to add a trial countdown timer to a React app?

The easiest way is to use a service like TrialMoments, which provides a pre-built, conversion-optimized countdown timer with a single line of initialization code. You call TrialMoments.init() with your account ID and trial end date, and it handles the countdown display, urgency states, responsive design, timezone handling, and upgrade prompts automatically. This avoids the 200+ lines of custom React code and the four major edge cases (SSR, hydration, timezones, tab throttling) you would need to handle manually.

Ready to Add a Trial Timer That Converts?

TrialMoments deploys conversion-optimized trial countdown timers in 5 minutes. No custom hooks, no edge cases, no maintenance.

Get Started with TrialMoments