React Free Trial Component Library for SaaS

Build a complete trial management UI system in React: context provider, custom hooks, countdown timer, upgrade prompts, banners, and expiration overlays. Full code for every component with props, usage examples, and the 3-line TrialMoments alternative.

By TrialMoments Team11 min readPublished Mar 2026
500+
Lines of DIY Code
6
Components Needed
3 lines
TrialMoments Setup

A React free trial component library is the set of UI components that manage the user-facing experience of a SaaS free trial. This includes countdown timers, upgrade prompts, warning banners, and expiration overlays -- all the pieces that guide trial users toward conversion.

Building these components from scratch is a common React exercise, but the total scope surprises most teams. A complete trial UI system requires a context provider for state management, a custom hook, four specialized UI components, and careful handling of edge cases like timezone math, interval cleanup, and responsive layouts. That's 500+ lines of well-tested code.

This guide walks through building each component with full TypeScript code, props interfaces, and usage examples. At the end, we'll show how TrialMoments SDK provides all of this functionality in 3 lines of code, working alongside React without any virtual DOM conflicts.

The 6 Components You Need

A complete React trial system requires these six components working together:

TrialProvider

Context provider that fetches and manages trial state. Wraps your entire app.

useTrialStatus

Custom hook that consumes trial context and provides computed state values.

CountdownTimer

Displays a live countdown to trial expiration with days, hours, and minutes.

UpgradePrompt

Modal dialog that presents the upgrade CTA with value proposition and pricing.

TrialBanner

Persistent notification bar showing trial status and days remaining.

TrialEndedOverlay

Full-screen overlay that blocks access when the trial has fully expired.

Component 1: TrialProvider Context

The TrialProvider is the foundation of your trial system. It fetches trial data from your API and provides it to all child components through React context:

// components/trial/TrialProvider.tsx
import React, { createContext, useState, useEffect, ReactNode } from 'react';

interface TrialState {
  trialEndDate: string | null;
  daysRemaining: number;
  hoursRemaining: number;
  status: 'active' | 'ending_soon' | 'last_day' | 'expired';
  isExpired: boolean;
  upgradeUrl: string;
  loading: boolean;
}

export const TrialContext = createContext<TrialState>({
  trialEndDate: null,
  daysRemaining: 0,
  hoursRemaining: 0,
  status: 'active',
  isExpired: false,
  upgradeUrl: '/upgrade',
  loading: true,
});

interface TrialProviderProps {
  children: ReactNode;
  upgradeUrl?: string;
}

export function TrialProvider({
  children,
  upgradeUrl = '/upgrade',
}: TrialProviderProps) {
  const [state, setState] = useState<TrialState>({
    trialEndDate: null,
    daysRemaining: 0,
    hoursRemaining: 0,
    status: 'active',
    isExpired: false,
    upgradeUrl,
    loading: true,
  });

  useEffect(() => {
    async function fetchTrialData() {
      try {
        const res = await fetch('/api/subscription');
        const data = await res.json();

        if (data.plan !== 'trial') return;

        const end = new Date(data.trialEndDate);
        const now = new Date();
        const msLeft = end.getTime() - now.getTime();
        const days = Math.max(0, Math.ceil(msLeft / 86400000));
        const hours = Math.max(0, Math.ceil(msLeft / 3600000));

        let status: TrialState['status'] = 'active';
        if (msLeft <= 0) status = 'expired';
        else if (msLeft <= 86400000) status = 'last_day';
        else if (msLeft <= 3 * 86400000) status = 'ending_soon';

        setState({
          trialEndDate: data.trialEndDate,
          daysRemaining: days,
          hoursRemaining: hours,
          status,
          isExpired: msLeft <= 0,
          upgradeUrl,
          loading: false,
        });
      } catch (err) {
        console.error('Failed to fetch trial data:', err);
        setState(prev => ({ ...prev, loading: false }));
      }
    }

    fetchTrialData();
  }, [upgradeUrl]);

  // Update countdown every minute
  useEffect(() => {
    if (!state.trialEndDate) return;

    const interval = setInterval(() => {
      const end = new Date(state.trialEndDate!);
      const now = new Date();
      const msLeft = end.getTime() - now.getTime();
      const days = Math.max(0, Math.ceil(msLeft / 86400000));
      const hours = Math.max(0, Math.ceil(msLeft / 3600000));

      let status: TrialState['status'] = 'active';
      if (msLeft <= 0) status = 'expired';
      else if (msLeft <= 86400000) status = 'last_day';
      else if (msLeft <= 3 * 86400000) status = 'ending_soon';

      setState(prev => ({
        ...prev,
        daysRemaining: days,
        hoursRemaining: hours,
        status,
        isExpired: msLeft <= 0,
      }));
    }, 60000);

    return () => clearInterval(interval);
  }, [state.trialEndDate]);

  return (
    <TrialContext.Provider value={state}>
      {children}
    </TrialContext.Provider>
  );
}

Usage: Wrap your app root with <TrialProvider> so all child components can access trial state.

Component 2: useTrialStatus Hook

The custom hook consumes the trial context and adds computed convenience values:

// hooks/useTrialStatus.ts
import { useContext } from 'react';
import { TrialContext } from '../components/trial/TrialProvider';

export function useTrialStatus() {
  const trial = useContext(TrialContext);

  return {
    ...trial,
    shouldShowWarning: trial.status === 'ending_soon'
      || trial.status === 'last_day',
    shouldShowBanner: trial.status !== 'expired'
      && !trial.loading,
    urgencyLevel: trial.status === 'last_day' ? 'high'
      : trial.status === 'ending_soon' ? 'medium' : 'low',
    displayText: trial.isExpired
      ? 'Your trial has ended'
      : trial.status === 'last_day'
        ? `${trial.hoursRemaining}h remaining`
        : `${trial.daysRemaining} days remaining`,
  };
}

// Usage in any component:
// const { daysRemaining, shouldShowWarning, displayText } = useTrialStatus();

Component 3: CountdownTimer

A live countdown that updates every second when the trial is on its last day:

// components/trial/CountdownTimer.tsx
import { useState, useEffect } from 'react';

interface CountdownTimerProps {
  trialEndDate: string;
  onExpired?: () => void;
  className?: string;
}

export function CountdownTimer({
  trialEndDate,
  onExpired,
  className = '',
}: CountdownTimerProps) {
  const [timeLeft, setTimeLeft] = useState({
    days: 0, hours: 0, minutes: 0, seconds: 0,
  });

  useEffect(() => {
    function updateCountdown() {
      const end = new Date(trialEndDate).getTime();
      const now = Date.now();
      const diff = Math.max(0, end - now);

      if (diff === 0) {
        onExpired?.();
        return;
      }

      setTimeLeft({
        days: Math.floor(diff / 86400000),
        hours: Math.floor((diff % 86400000) / 3600000),
        minutes: Math.floor((diff % 3600000) / 60000),
        seconds: Math.floor((diff % 60000) / 1000),
      });
    }

    updateCountdown();
    const interval = setInterval(updateCountdown, 1000);
    return () => clearInterval(interval);
  }, [trialEndDate, onExpired]);

  return (
    <div className={className}>
      {timeLeft.days > 0 && (
        <span>{timeLeft.days}d </span>
      )}
      <span>{String(timeLeft.hours).padStart(2, '0')}:</span>
      <span>{String(timeLeft.minutes).padStart(2, '0')}:</span>
      <span>{String(timeLeft.seconds).padStart(2, '0')}</span>
    </div>
  );
}

// Usage:
// <CountdownTimer
//   trialEndDate="2026-04-15T00:00:00Z"
//   onExpired={() => setShowExpiredOverlay(true)}
//   className="text-2xl font-mono font-bold"
// />

Component 4: UpgradePrompt

A modal dialog that shows when the user clicks "Upgrade" or hits a gated feature:

// components/trial/UpgradePrompt.tsx
import { useTrialStatus } from '../../hooks/useTrialStatus';

interface UpgradePromptProps {
  isOpen: boolean;
  onClose: () => void;
  featureName?: string;
}

export function UpgradePrompt({
  isOpen, onClose, featureName,
}: UpgradePromptProps) {
  const { daysRemaining, upgradeUrl, isExpired } = useTrialStatus();

  if (!isOpen) return null;

  return (
    <div className="fixed inset-0 z-50 flex items-center
      justify-center bg-black/50"
    >
      <div className="bg-white dark:bg-gray-900 rounded-xl
        shadow-xl max-w-md w-full mx-4 p-8"
      >
        <h2 className="text-2xl font-bold mb-4">
          {featureName
            ? `Unlock ${featureName}`
            : 'Upgrade Your Plan'}
        </h2>

        <p className="text-gray-600 dark:text-gray-400 mb-6">
          {isExpired
            ? 'Your trial has ended. Upgrade to access all features.'
            : `You have ${daysRemaining} days left. Upgrade now to
              ensure uninterrupted access.`}
        </p>

        {featureName && (
          <p className="text-sm text-gray-500 mb-4">
            "{featureName}" is available on paid plans.
          </p>
        )}

        <div className="flex gap-3">
          <a
            href={upgradeUrl}
            className="flex-1 bg-blue-600 text-white text-center
              py-3 px-4 rounded-lg font-semibold hover:bg-blue-700"
          >
            Upgrade Now
          </a>
          {!isExpired && (
            <button
              onClick={onClose}
              className="px-4 py-3 rounded-lg border
                border-gray-300 hover:bg-gray-50"
            >
              Later
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

Component 5: TrialBanner

A persistent banner that sits at the top of your app showing trial status:

// components/trial/TrialBanner.tsx
import { useState } from 'react';
import { useTrialStatus } from '../../hooks/useTrialStatus';

export function TrialBanner() {
  const {
    shouldShowBanner, displayText, urgencyLevel,
    upgradeUrl, isExpired,
  } = useTrialStatus();
  const [dismissed, setDismissed] = useState(false);

  if (!shouldShowBanner || dismissed || isExpired) return null;

  const bgColor = urgencyLevel === 'high'
    ? 'bg-red-600' : urgencyLevel === 'medium'
    ? 'bg-yellow-500' : 'bg-blue-600';

  return (
    <div className={`${bgColor} text-white px-4 py-2
      flex items-center justify-center gap-4 text-sm`}
    >
      <span>{displayText}</span>
      <a
        href={upgradeUrl}
        className="underline font-semibold hover:no-underline"
      >
        Upgrade Now
      </a>
      <button
        onClick={() => setDismissed(true)}
        className="ml-2 opacity-70 hover:opacity-100"
        aria-label="Dismiss"
      >
        &times;
      </button>
    </div>
  );
}

Component 6: TrialEndedOverlay

The full-screen overlay that blocks access when the trial expires:

// components/trial/TrialEndedOverlay.tsx
import { useTrialStatus } from '../../hooks/useTrialStatus';

export function TrialEndedOverlay() {
  const { isExpired, upgradeUrl } = useTrialStatus();

  if (!isExpired) return null;

  return (
    <div className="fixed inset-0 z-[100] flex items-center
      justify-center bg-black/80 backdrop-blur-sm"
    >
      <div className="bg-white dark:bg-gray-900 rounded-2xl
        shadow-2xl max-w-lg w-full mx-4 p-10 text-center"
      >
        <div className="text-6xl mb-6">&#9203;</div>
        <h2 className="text-3xl font-bold mb-4">
          Your Trial Has Ended
        </h2>
        <p className="text-gray-600 dark:text-gray-400 mb-8
          text-lg leading-relaxed"
        >
          Upgrade now to continue using all features and
          keep your data. Your work is safe and waiting
          for you.
        </p>
        <a
          href={upgradeUrl}
          className="inline-block bg-blue-600 text-white
            text-lg font-semibold py-4 px-8 rounded-lg
            hover:bg-blue-700 transition-colors"
        >
          Upgrade to Continue
        </a>
        <p className="mt-6 text-sm text-gray-500">
          Questions?{' '}
          <a href="/contact" className="underline">
            Contact support
          </a>
        </p>
      </div>
    </div>
  );
}

The Reality: 500+ Lines and Growing

If you've followed along, you now have ~450 lines of React code. But we haven't covered:

Responsive design -- Each component needs mobile, tablet, and desktop layouts

Dark mode -- Every component needs light and dark theme support

Animations -- Smooth transitions for showing/hiding banners, modals, and overlays

localStorage persistence -- Remember dismissals across sessions

Accessibility -- Focus management, ARIA labels, keyboard navigation

Testing -- Unit tests for timer logic, integration tests for the full flow

Conversion copy -- What messages actually drive upgrades? Requires A/B testing

The complete scope is 800-1200 lines of production code, 2-3 weeks of development, and ongoing maintenance as your product evolves.

That's a significant investment for trial UI that isn't your core product. Most teams discover mid-build that the scope is larger than expected and ship an incomplete implementation that hurts conversion more than it helps.

The 3-Line Alternative: TrialMoments with React

TrialMoments SDK works alongside React without any conflicts. It renders in its own DOM container, separate from React's virtual DOM tree. Here's the complete integration:

// In your index.html or _document.tsx:
// <script src="https://cdn.trialmoments.com/sdk.js"></script>

// In your App.tsx or layout component:
import { useEffect } from 'react';

function App() {
  useEffect(() => {
    // Initialize TrialMoments once on mount
    if (window.TrialMoments && user.plan === 'trial') {
      TrialMoments.init({
        accountId: 'your-account-id',
        trialEndDate: user.trialEndDate,
        upgradeUrl: 'https://yourapp.com/upgrade'
      });
    }
  }, []);

  return <YourApp />;
}

// For blocked features in any React component:
function PremiumFeatureButton() {
  const handleClick = () => {
    if (!user.hasAccess('advanced-export')) {
      TrialMoments.triggerBlockedFeature('advanced-export');
      return;
    }
    // Normal feature logic
    performExport();
  };

  return (
    <button onClick={handleClick}>
      Advanced Export
    </button>
  );
}

Custom React Build

  • 6 components to build and maintain
  • 500-1200 lines of code
  • 2-3 weeks of development
  • Ongoing maintenance burden
  • Conversion copy you need to write and test
  • No analytics built in

TrialMoments SDK

  • 3 lines to initialize
  • 30KB bundle, zero dependencies
  • 5 minutes to production
  • No React conflicts (separate DOM)
  • Proven conversion copy built in
  • Analytics dashboard included

Done-for-You React Trial UI

Replace 500+ lines of custom React components with 3 lines of TrialMoments initialization. All five conversion moments, dark mode, responsive design, and proven copy -- done for you. Works alongside React without conflicts.

Frequently Asked Questions

How do I build a trial countdown timer in React?

Create a CountdownTimer component that takes a trialEndDate prop. Use useState to track the remaining time and useEffect with setInterval to update every second. Calculate days, hours, and minutes from the millisecond difference between the end date and Date.now(). Remember to clear the interval on unmount and handle the edge case where the timer reaches zero by triggering an onExpired callback.

What React components do I need for a complete trial system?

A complete React trial system needs six components: TrialProvider (context provider for trial state), useTrialStatus hook (consumes context and provides computed state), CountdownTimer (displays remaining time), UpgradePrompt (modal or inline upgrade CTA), TrialBanner (persistent notification bar), and TrialEndedOverlay (full-screen block when trial expires). Together these require 500+ lines of code covering state management, side effects, and styling.

Can I use TrialMoments with React instead of building custom components?

Yes, TrialMoments works alongside React without any conflict. Since it loads via a script tag and manages its own DOM outside React's virtual DOM tree, there are no reconciliation issues. You initialize it once (typically in a useEffect in your App component) and it handles all trial UI automatically. This replaces 500+ lines of custom React code with 3 lines of initialization.

Does TrialMoments conflict with React's virtual DOM?

No, TrialMoments does not conflict with React's virtual DOM. The SDK renders its UI elements in a separate DOM container that React doesn't manage. This is the same pattern used by modal libraries and toast notifications. React manages your app components while TrialMoments manages trial UI in its own container. There are no hydration issues, no reconciliation conflicts, and no performance overhead.

How do I handle trial state in React context?

Create a TrialContext using React.createContext with a TrialProvider component that fetches trial data from your API. Store trialEndDate, daysRemaining, trialStatus, and isExpired in state. Expose these values plus an upgradeUrl through the context. Create a useTrialStatus custom hook that consumes this context and adds computed properties like urgencyLevel and shouldShowWarning. Wrap your app with TrialProvider.

Ship Trial Conversion Today, Not Next Month

TrialMoments gives your React app a complete, done-for-you trial experience. 30KB, 3 lines, 5 conversion moments. No custom components required.

Get Started with TrialMoments