A trial expiration banner is a notification bar displayed at the top or bottom of your SaaS application that informs trial users how much time remains before their trial ends. It is one of the most effective in-app conversion tools available, with well-implemented banners improving trial-to-paid conversion by 15-25% compared to relying on email reminders alone.
This tutorial walks through the full implementation of a trial expiration banner. We will start with the HTML structure, add CSS styling with progressive urgency colors, implement JavaScript countdown logic, handle responsive design, address accessibility requirements, and build dismissal behavior with session persistence. At the end, we will show how TrialMoments delivers all of this in 3 lines of code as part of its Trial Ending Soon moment.
Whether you build it yourself or use a done-for-you solution, you will leave this guide with a production-ready trial expiration banner that respects your users while driving measurable conversion improvements.
Step 1: HTML Banner Structure
The banner needs four elements: a container, a message area, a CTA button, and a dismiss button. We use semantic HTML with ARIA attributes for accessibility from the start.
<!-- trial-banner.html -->
<div
id="trial-banner"
role="status"
aria-live="polite"
class="trial-banner trial-banner--info"
>
<div class="trial-banner__content">
<span class="trial-banner__icon" aria-hidden="true">
⏰
</span>
<p class="trial-banner__message">
Your free trial ends in
<strong id="trial-days-left">14 days</strong>.
Upgrade now to keep all your data and features.
</p>
</div>
<div class="trial-banner__actions">
<a
href="/billing/upgrade"
class="trial-banner__cta"
>
Upgrade Now
</a>
<button
class="trial-banner__dismiss"
aria-label="Dismiss trial banner"
onclick="dismissTrialBanner()"
>
×
</button>
</div>
</div>Step 2: CSS Styling with Progressive Urgency
The CSS implements a progressive urgency system that changes the banner appearance as the trial deadline approaches. This is critical: a static banner gets ignored after the first few sessions, but one that visually escalates maintains attention through the entire trial lifecycle.
/* trial-banner.css */
.trial-banner {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
font-size: 14px;
line-height: 1.4;
transition: background-color 0.3s ease,
color 0.3s ease;
}
.trial-banner__content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.trial-banner__actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.trial-banner__cta {
padding: 6px 16px;
border-radius: 6px;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
transition: opacity 0.2s;
}
.trial-banner__cta:hover {
opacity: 0.9;
}
.trial-banner__dismiss {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
opacity: 0.7;
transition: opacity 0.2s;
}
.trial-banner__dismiss:hover {
opacity: 1;
}
/* Progressive Urgency Colors */
/* Info: 7+ days remaining (green) */
.trial-banner--info {
background-color: #ecfdf5;
color: #065f46;
}
.trial-banner--info .trial-banner__cta {
background-color: #059669;
color: white;
}
/* Warning: 3-7 days remaining (yellow) */
.trial-banner--warning {
background-color: #fefce8;
color: #854d0e;
}
.trial-banner--warning .trial-banner__cta {
background-color: #ca8a04;
color: white;
}
/* Urgent: 1-3 days remaining (orange) */
.trial-banner--urgent {
background-color: #fff7ed;
color: #9a3412;
}
.trial-banner--urgent .trial-banner__cta {
background-color: #ea580c;
color: white;
}
/* Critical: <24 hours remaining (red) */
.trial-banner--critical {
background-color: #fef2f2;
color: #991b1b;
}
.trial-banner--critical .trial-banner__cta {
background-color: #dc2626;
color: white;
}
/* Responsive: Stack vertically on mobile */
@media (max-width: 640px) {
.trial-banner {
flex-direction: column;
gap: 8px;
text-align: center;
padding: 12px 16px;
}
.trial-banner__actions {
width: 100%;
justify-content: center;
}
}
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.trial-banner {
transition: none;
}
}Why Progressive Colors Matter
Users process color faster than text. A green banner communicates "no rush" at a glance, while a red banner triggers urgency without the user reading a single word. This leverages the same traffic light psychology that humans internalize from childhood. Banners with progressive urgency convert 40% better than static single-color banners because they prevent habituation and create a sense of accelerating time pressure.
Step 3: JavaScript Countdown Logic
The JavaScript handles three responsibilities: calculating the remaining trial time, updating the banner message and urgency class, and managing the countdown timer that refreshes the display. Here is the full implementation:
// trial-banner.js
class TrialBanner {
constructor(trialEndDate, upgradeUrl) {
this.trialEndDate = new Date(trialEndDate);
this.upgradeUrl = upgradeUrl;
this.banner = document.getElementById('trial-banner');
this.daysLeftEl = document.getElementById('trial-days-left');
this.interval = null;
}
init() {
// Check if banner was dismissed this session
const dismissed = sessionStorage.getItem('trial-banner-dismissed');
if (dismissed) {
this.banner.style.display = 'none';
return;
}
this.update();
// Update every minute
this.interval = setInterval(() => this.update(), 60000);
}
getTimeRemaining() {
const now = new Date();
const diff = this.trialEndDate - now;
return {
total: diff,
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
minutes: Math.floor((diff / (1000 * 60)) % 60),
};
}
getUrgencyLevel(days, hours) {
if (days < 1) return 'critical';
if (days < 3) return 'urgent';
if (days < 7) return 'warning';
return 'info';
}
formatTimeLeft(time) {
if (time.total <= 0) return 'Your trial has expired';
if (time.days > 1) return `${time.days} days`;
if (time.days === 1) return '1 day';
if (time.hours > 0) return `${time.hours} hours`;
return `${time.minutes} minutes`;
}
update() {
const time = this.getTimeRemaining();
if (time.total <= 0) {
this.daysLeftEl.textContent = 'expired';
this.banner.querySelector('.trial-banner__message')
.innerHTML = 'Your trial has <strong>expired</strong>. '
+ 'Upgrade now to restore access to all features.';
this.setUrgency('critical');
clearInterval(this.interval);
return;
}
this.daysLeftEl.textContent = this.formatTimeLeft(time);
this.setUrgency(
this.getUrgencyLevel(time.days, time.hours)
);
}
setUrgency(level) {
// Remove all urgency classes
this.banner.classList.remove(
'trial-banner--info',
'trial-banner--warning',
'trial-banner--urgent',
'trial-banner--critical'
);
this.banner.classList.add(`trial-banner--${level}`);
}
dismiss() {
this.banner.style.display = 'none';
sessionStorage.setItem(
'trial-banner-dismissed', 'true'
);
}
}
// Initialize
const banner = new TrialBanner(
'2026-04-03T00:00:00Z', // from your backend
'/billing/upgrade'
);
banner.init();
// Global dismiss function for onclick
window.dismissTrialBanner = () => banner.dismiss();Step 4: Responsive Design Patterns
Trial banners must work across every viewport your users encounter. On desktop, a horizontal layout with message left and actions right works well. On mobile, the banner needs to stack vertically to prevent text truncation and ensure the CTA button remains tappable.
Key Responsive Considerations
position: sticky instead of position: fixed to avoid overlapping content. Sticky banners scroll naturally with the page header and feel less intrusive.Step 5: Accessibility Considerations
Accessibility is not optional. Beyond being the right thing to do, many SaaS customers in enterprise and government require WCAG 2.1 AA compliance. Here is what your trial banner needs:
ARIA Roles and Live Regions
Use role="status" and aria-live="polite" on the banner container. This tells screen readers to announce changes to the countdown without interrupting what the user is currently doing. When the urgency level changes (e.g., from warning to urgent), the screen reader will announce the new message at the next natural pause.
Color Contrast and Non-Color Indicators
Ensure all text-to-background color combinations meet the 4.5:1 contrast ratio minimum. Beyond color, add text labels like "Urgent:" or "Final day:" as prefixes to the message so color-blind users understand the urgency level. The CSS examples above use dark text on light backgrounds to maintain contrast across all urgency states.
Keyboard Navigation
The CTA link and dismiss button must be reachable via Tab key. Add visible focus indicators (outline or ring) on both interactive elements. The dismiss button needs an aria-label since it uses a visual symbol (x) that has no semantic meaning for screen readers. Test with keyboard-only navigation to ensure the tab order is logical: message, CTA, then dismiss.
Reduced Motion Support
If you add pulse animations or transitions to the urgent/critical states, wrap them in a prefers-reduced-motion media query. Users who have enabled reduced motion in their OS settings should see static color changes without any animation. The CSS we provided above includes this consideration.
Step 6: Dismissal Behavior and Session Persistence
The dismissal pattern you choose directly affects conversion. Too persistent and users get frustrated. Too easy to permanently dismiss and you lose the conversion opportunity. Here are the three approaches, ranked by effectiveness:
// Approach 1: Session-based dismissal (Recommended)
// Banner reappears on every new session
function dismiss() {
sessionStorage.setItem('trial-banner-dismissed', 'true');
banner.style.display = 'none';
}
// Approach 2: Time-based re-show
// Banner reappears after N hours
function dismiss() {
const reappearAt = Date.now() + (8 * 60 * 60 * 1000);
localStorage.setItem('trial-banner-reappear', reappearAt);
banner.style.display = 'none';
}
function shouldShow() {
const reappearAt = localStorage.getItem(
'trial-banner-reappear'
);
return !reappearAt || Date.now() >= Number(reappearAt);
}
// Approach 3: Urgency-level re-show
// Banner reappears when urgency level changes
function dismiss(currentLevel) {
localStorage.setItem(
'trial-banner-dismissed-level', currentLevel
);
banner.style.display = 'none';
}
function shouldShow(currentLevel) {
const dismissedLevel = localStorage.getItem(
'trial-banner-dismissed-level'
);
return currentLevel !== dismissedLevel;
}Recommended: Urgency-Level Re-show
Approach 3 is the highest-converting pattern. When a user dismisses the banner at the "info" level (7+ days), it stays hidden until the urgency changes to "warning" (under 7 days). This respects the user's choice while ensuring they see the banner again when the situation has genuinely changed. TrialMoments uses this approach by default in its Trial Ending Soon moment.
The Done-for-You Alternative: 3 Lines of Code
Everything above, including the progressive urgency, accessibility, responsive design, and smart dismissal, takes a developer 4-8 hours to build, test, and ship. TrialMoments provides all of it as its Trial Ending Soon moment with a 3-line integration:
<script src="https://cdn.trialmoments.com/sdk.js"></script>
<script>
TrialMoments.init({
apiKey: 'tm_your_api_key',
user: { id: 'user_123', trialEndsAt: '2026-04-03T00:00:00Z', plan: 'trial' },
upgradeUrl: 'https://yourapp.com/billing/upgrade'
});
</script>
// The Trial Ending Soon banner activates automatically.
// Plus you get 4 more conversion moments:
// - First Load Welcome
// - Blocked Feature Prompt (35% upgrade rate)
// - Trial Ended State
// - Floating Trial WidgetBonus: React Component Implementation
If your SaaS application uses React, here is a component version of the trial banner with hooks for state management and cleanup:
// TrialBanner.tsx
import { useState, useEffect, useCallback } from 'react';
type UrgencyLevel = 'info' | 'warning' | 'urgent' | 'critical';
interface TrialBannerProps {
trialEndsAt: string;
upgradeUrl: string;
}
export function TrialBanner({ trialEndsAt, upgradeUrl }: TrialBannerProps) {
const [timeLeft, setTimeLeft] = useState('');
const [urgency, setUrgency] = useState<UrgencyLevel>('info');
const [dismissed, setDismissed] = useState(false);
const calculate = useCallback(() => {
const diff = new Date(trialEndsAt).getTime() - Date.now();
const days = Math.floor(diff / 86400000);
const hours = Math.floor((diff % 86400000) / 3600000);
if (diff <= 0) {
setTimeLeft('expired');
setUrgency('critical');
return;
}
setTimeLeft(days > 0 ? `${days}d ${hours}h` : `${hours}h`);
setUrgency(
days < 1 ? 'critical' :
days < 3 ? 'urgent' :
days < 7 ? 'warning' : 'info'
);
}, [trialEndsAt]);
useEffect(() => {
calculate();
const interval = setInterval(calculate, 60000);
return () => clearInterval(interval);
}, [calculate]);
useEffect(() => {
const dismissedLevel = sessionStorage.getItem(
'trial-banner-dismissed-level'
);
if (dismissedLevel === urgency) setDismissed(true);
else setDismissed(false);
}, [urgency]);
const handleDismiss = () => {
sessionStorage.setItem(
'trial-banner-dismissed-level', urgency
);
setDismissed(true);
};
if (dismissed) return null;
const colors = {
info: 'bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100',
warning: 'bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100',
urgent: 'bg-orange-50 text-orange-900 dark:bg-orange-950 dark:text-orange-100',
critical: 'bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100',
};
return (
<div role="status" aria-live="polite"
className={`sticky top-0 z-50 flex items-center
justify-between px-6 py-3 text-sm ${colors[urgency]}`}
>
<p>
Trial {timeLeft === 'expired' ? 'has' : 'ends in'}
{' '}<strong>{timeLeft}</strong>.
{' '}Upgrade to keep all features.
</p>
<div className="flex items-center gap-3">
<a href={upgradeUrl}
className="px-4 py-1.5 rounded-md font-semibold
bg-current/20 hover:bg-current/30"
>
Upgrade Now
</a>
<button onClick={handleDismiss}
aria-label="Dismiss trial banner"
className="opacity-70 hover:opacity-100 text-lg"
>
×
</button>
</div>
</div>
);
}This React implementation includes automatic cleanup of the interval timer, urgency-level-based dismissal, and dark mode support. It is a solid starting point, but keep in mind you still need to handle the responsive stacking, additional accessibility testing, and the other four conversion moments that TrialMoments provides out of the box.
Conversion Impact: What to Expect
Based on data from SaaS products implementing trial expiration banners, here is the conversion impact by implementation quality:
The reason TrialMoments outperforms a standalone banner is that the Trial Ending Soon moment works in concert with the other four moments. The Floating Widget maintains persistent awareness. The Blocked Feature Prompt captures high-intent moments at 35% conversion. The First Load Welcome sets expectations early. And the Trial Ended State converts users who miss the deadline. Together, these five moments cover every stage of the trial conversion funnel.
Common Mistakes to Avoid
Mistake 1: Making the Banner Non-Dismissible From Day One
A banner that cannot be closed on day 1 of a 14-day trial signals disrespect for the user. Allow dismissal early in the trial and only consider persistence in the final 24 hours when genuine urgency justifies it.
Mistake 2: Using Fixed Positioning That Covers Content
A position: fixed banner without adjusting the body padding means the banner overlaps your app content. Use position: sticky or add dynamic top-padding to the body element equal to the banner height. Test on every viewport size.
Mistake 3: Forgetting Timezone Handling
If your trial end date is stored in UTC but you calculate remaining time using the user's local timezone incorrectly, the countdown will be wrong. Always use ISO 8601 UTC dates and let the browser's Date constructor handle timezone conversion. The JavaScript example above handles this correctly.
FAQ: Trial Expiration Banners
How do I add a trial expiration banner to my SaaS app?
You can add a trial expiration banner in two ways. The DIY approach involves creating an HTML banner element, styling it with CSS for progressive urgency colors, adding JavaScript countdown logic that calculates remaining time from your trial end date, and implementing dismissal behavior with localStorage persistence. This typically takes 4-8 hours. Alternatively, TrialMoments provides a done-for-you trial expiration banner through its Trial Ending Soon moment that deploys in 3 lines of code and under 5 minutes.
What colors should a trial expiration banner use?
Trial expiration banners should use progressive urgency colors that escalate as the deadline approaches. Use green or blue for early trial stages (more than 7 days remaining), yellow or amber for 3-7 days, orange for the final 1-3 days, and red for the last 24 hours. This color progression mirrors traffic light psychology that users instinctively understand, and it prevents banner blindness by changing the visual appearance over time.
Should a trial banner be dismissible?
Yes, trial banners should always include a dismiss option to respect user autonomy. However, the dismiss behavior should be temporary, not permanent. Best practice is to re-show the banner when the urgency level changes (e.g., from "info" to "warning"). Store the dismissed urgency level in localStorage or sessionStorage. In the final 24 hours, you may make the banner persistent since the urgency genuinely warrants it.
How do I make a trial expiration banner accessible?
Accessible trial banners require role="status" or role="alert" ARIA attributes for screen reader announcements, sufficient color contrast ratios (4.5:1 minimum), text labels alongside color changes for color-blind users, keyboard-navigable interactive elements with visible focus indicators, and prefers-reduced-motion support to disable animations. The dismiss button needs an aria-label describing its action since the "x" symbol has no semantic meaning.
Can I add a trial expiration banner without modifying my backend?
Yes. A client-side trial expiration banner can calculate remaining time from a trial end date passed to the frontend during authentication or page load. Store the trial end date in your user session or JWT token, then pass it to the banner component. TrialMoments works entirely client-side after you provide the user's trial end date during initialization, requiring zero backend changes to deploy all 5 conversion moments including the trial expiration banner.
Ship a Trial Banner Today, Not Next Sprint
TrialMoments gives you a production-ready trial expiration banner plus 4 more conversion moments in a single 30KB SDK. Free for up to 20 trial users. No backend changes required.
Start Free with TrialMoments