Back to Journal

Dismissible referral modal with localStorage

Listen to this articleAI narration
0:00 / 0:00

The product team wanted a referral announcement popup. Not a one-and-done dismissal - the kind that comes back. The requirement: show the modal on every authenticated page, let the user close it, but bring it back on subsequent visits until they've dismissed it three times. After the third dismissal, it disappears permanently. And it needs to mirror the full referral page layout - two-column grids, referral code card, step-by-step breakdown, benefit sections - all inside a modal.

Simple on the surface. A few edge cases underneath.

The Dismiss Mechanism

The core behavior revolves around a localStorage counter. On mount, the component reads the current count. If it's below the threshold, a delayed timer opens the modal. On close, the count increments and persists.

const STORAGE_KEY = 'announcement_dismiss_count';
const MAX_DISMISSALS = 3;
const DELAY_MS = 1500;
 
useEffect(() => {
  const count = parseInt(localStorage.getItem(STORAGE_KEY) || '0', 10);
 
  if (count >= MAX_DISMISSALS) {
    return;
  }
 
  const timer = setTimeout(() => setIsOpen(true), DELAY_MS);
  return () => clearTimeout(timer);
}, []);
 
const close = () => {
  if (!isImpersonating) {
    const count = parseInt(localStorage.getItem(STORAGE_KEY) || '0', 10);
    localStorage.setItem(STORAGE_KEY, String(count + 1));
  }
  setIsOpen(false);
};

The 1.5-second delay prevents the modal from competing with the initial page render. The cleanup function in useEffect handles cases where the component unmounts before the timer fires - navigation away during that 1.5-second window, for instance.

localStorage Dismiss FlowPage LoaduseEffect firesRead CountlocalStorage → int< 3≥ 3Show ModalDo NothingUser Dismissescount++ → localStorage

Every dismiss path - the close button on the Modal component, the "Stäng" secondary button, and the "Läs mer" primary button that navigates to the referral page - all funnel through the same close() function. One counter, one code path, no drift.

The Impersonation Guard

The platform supports admin impersonation. An admin can assume a user's session to debug issues or verify behavior. Without a guard, an admin dismissing the modal while impersonating would burn through the user's three dismissals.

const {state: {user, isImpersonating}} = useUser();
 
const close = () => {
  if (!isImpersonating) {
    const count = parseInt(localStorage.getItem(STORAGE_KEY) || '0', 10);
    localStorage.setItem(STORAGE_KEY, String(count + 1));
  }
  setIsOpen(false);
};

The modal still renders and can be closed during impersonation. It just doesn't touch localStorage. The real user's dismiss count stays untouched regardless of how many times an admin previews the experience.

Impersonation GuardUser clicks dismissclose() calledisImpersonating: falsecount++ → localStoragesetIsOpen(false)isImpersonating: trueskip localStoragesetIsOpen(false)

Fitting a Full Page Layout into a Modal

The existing Modal component was hardcoded to max-w-[640px]. That's fine for confirmation dialogs and form modals, but the referral content uses a two-column grid layout that collapses at that width.

We extended Modal to accept an optional className prop. When provided, it overrides the default max-width on both the <dialog> element and the inner container. When omitted, existing modals are unchanged - fully backwards-compatible.

export default function Modal({
  children, open = false, hideClose = false, className, onClose = value => {}
}) {
  const $maxWidth = className || 'max-w-[640px]';
 
  return <dialog className={clsx(
    'w-full backdrop:bg-layer-overlay open:flex my-auto mx-auto modal bg-none rounded-panel',
    $maxWidth
  )}>
    <div className={clsx('w-full my-auto mx-auto bg-base-negative p-4 md:p-8 relative m-8', $maxWidth)}>
      {children}
    </div>
  </dialog>
}

The announcement modal passes className="max-w-[900px]", giving enough room for the side-by-side cards without breaking smaller modals elsewhere.

Content Structure

The modal mirrors the referral page layout with three sections stacked vertically:

┌─────────────────────────────────────────────┐
│           Hero: Heading + Subtitle          │
│        Description text (centered)          │
├─────────────────────────────────────────────┤
│  ┌───────────────┐  ┌───────────────────┐  │
│  │  Det här      │  │  Det här får      │  │
│  │  får du       │  │  din vän          │  │
│  │  (benefits)   │  │  (friend gets)    │  │
│  └───────────────┘  └───────────────────┘  │
├─────────────────────────────────────────────┤
│  ┌───────────────┐  ┌───────────────────┐  │
│  │  Din kod      │  │  Så funkar det    │  │
│  │  (code card)  │  │  (steps card)     │  │
│  │  + Copy btn   │  │  + proBonus note  │  │
│  └───────────────┘  └───────────────────┘  │
├─────────────────────────────────────────────┤
│        [Läs mer]    [Stäng]                 │
└─────────────────────────────────────────────┘

All text flows through the existing referral.* i18n keys - no new translation entries needed. Both Swedish and English work out of the box because the referral page translations were already comprehensive. The referral code card conditionally renders based on user.referralCode - if there's no code, the card simply isn't shown rather than rendering an empty state.

Both grid sections use grid-cols-1 md:grid-cols-2 so the layout stacks vertically on mobile and goes side-by-side on desktop, matching the referral page behavior exactly.

Placement

The modal sits in the App layout alongside <Toasts/>, outside the main content grid. It renders for every authenticated route - dashboard, invoices, salaries, settings, referral page itself. The component self-manages visibility entirely through localStorage. No props, no parent state, no context wiring.

<Toasts/>
<AnnouncementModal/>

The modal only renders on authenticated pages because it lives inside the App layout, which already guards against unauthenticated access via Navigate. No additional auth check needed.

Files Changed

Four files total. One new component at src/components/Base/AnnouncementModal.jsx containing all the modal logic, dismiss tracking, and referral content layout. One modification to src/components/Base/Modal.jsx adding the className prop for width override support. One edit to src/layouts/App.jsx importing and rendering the modal. And src/i18n/locales/ untouched - no new translation keys since all text references existing referral.* entries.

What Could Change

The MAX_DISMISSALS constant is hardcoded to 3. If the product team wants to adjust the threshold or switch to a date-based expiry instead of a count-based one, the logic lives in one place. The localStorage key is namespaced clearly enough that multiple announcement campaigns could coexist with different keys. And the className prop on Modal opens the door for other wide-layout modals without further changes to the base component.

The simplest feature request turned into a lesson in edge case thinking. The modal itself took ten minutes. The impersonation guard, the width constraint, the i18n reuse strategy, and the cleanup around unnecessary translation keys - those were the real decisions.