Skip to main content

Why We Replaced "mailto:" Links with a Contact Modal (and How We Built It)

6 min read
Why We Replaced "mailto:" Links with a Contact Modal (and How We Built It)

Every business website has a "Contact us" button. Most of them do the same thing: slap a mailto: link on it and call it a day. We did too — until we realized how many potential clients we were losing because of it.

Here's what we changed, why, and how you can do the same thing in Next.js with Tailwind CSS.

When someone clicks a mailto: link, the browser opens whatever email client is set as default. Sounds fine, right? Here's why it isn't:

  1. Many people don't have a mail client configured. Especially on desktop, people use Gmail, Outlook, or Hey in the browser. Clicking mailto: either opens an app they never use or throws an error.

  2. It's a dead end. The user leaves your site. They might not come back. Your carefully crafted page is gone, replaced by a blank email compose window.

  3. There's no fallback. If someone just wants to grab the email address and paste it into their preferred app, they have to right-click, "Copy email address" — assuming they even know that's possible.

  4. Mobile is unpredictable. On iOS it opens Mail.app. On Android it might show a chooser — or it might not. The experience varies wildly.

The core issue: mailto: assumes everyone uses email the same way. They don't.

What we built instead

A contact modal that gives visitors three clear options:

  • Copy the email address to clipboard (one click)
  • Open the default mail app (same as before, but now it's a choice, not a default)
  • Contact us on X (alternative channel for people who prefer DMs)

The modal is triggered from two places: a "Contact us" button in the header navigation, and the existing CTA in the contact section at the bottom of the page.

The implementation

We're using Next.js 16 with the App Router, Tailwind CSS v4, Framer Motion for animations, and next-intl for i18n. Here's the approach.

1. The modal component

The modal itself is a standard overlay pattern: a semi-transparent backdrop with a centered panel. Nothing fancy in terms of structure, but a few details matter:

// Key behaviors:
// - Escape key closes it
// - Clicking the backdrop closes it
// - Body scroll is locked while open
// - Focus is trapped inside the panel

useEffect(() => {
  if (!open) return;
  document.body.style.overflow = 'hidden';
  const onKey = (e: KeyboardEvent) => {
    if (e.key === 'Escape') onClose();
  };
  window.addEventListener('keydown', onKey);
  return () => {
    document.body.style.overflow = '';
    window.removeEventListener('keydown', onKey);
  };
}, [open, onClose]);

The copy-to-clipboard action gives immediate feedback — the button text changes to "Copied!" for two seconds, so users know it worked without any toast notification.

2. Shared state with React Context

The modal needs to be openable from multiple places (header, contact section, footer), so we created a simple context provider:

const ContactModalContext = createContext<{ open: () => void }>({
  open: () => {},
});

export function useContactModal() {
  return useContext(ContactModalContext);
}

The provider wraps the app layout and renders the modal once. Any component can call useContactModal().open() to trigger it. No prop drilling, no state duplication.

3. Placement: header and bottom of page

We added the "Contact us" button in two strategic locations:

  • Header navigation — always visible, a small pill-shaped button that stands out from the text navigation links. This is where users look when they're ready to act.
  • Contact section CTA — the existing "Send me an email" button at the bottom now opens the modal instead of firing a mailto: link. Same position, better behavior.
  • Footer — an additional contact button between the nav links and social icons.

4. Internationalization

Since the site supports English and Finnish, every string in the modal is translated through next-intl:

{
  "contactModal": {
    "emailHeading": "Email us directly",
    "copyEmail": "Copy email",
    "copied": "Copied!",
    "openMailApp": "Open mail app",
    "xHeading": "Or contact us on X"
  }
}

5. Animation

Framer Motion handles the entrance and exit:

  • Backdrop fades in (opacity 0 → 1)
  • Panel scales up slightly and fades in (scale 0.95 → 1, opacity 0 → 1)
  • Both reverse on close via AnimatePresence

The easing curve [0.16, 1, 0.3, 1] matches the rest of the site's animations for consistency.

Design decisions

Why not a full contact form? Forms create friction. They require backend handling, spam protection, and validation. For a small studio, an email address and a social handle are enough. The modal reduces friction while keeping things simple.

Why include X as an option? Some people — especially developers and potential collaborators — prefer to reach out via DMs. Offering an alternative channel lowers the barrier to contact.

Why a modal instead of a dropdown? A dropdown would work for the header, but not for the contact section CTA. A modal is consistent everywhere and provides enough space to present the options clearly without cluttering the page.

Why not use a headless UI library like Radix or Headless UI? We could have, and for a larger app we would. But for a single modal with straightforward behavior, the custom implementation is ~80 lines of code with no additional dependency. The tradeoff favors simplicity here.

Results

The change is small in terms of code — about 150 lines for the modal component and provider, plus minor edits to the header, footer, and contact section. But the UX improvement is significant:

  • Visitors can grab the email address in one click
  • Nobody gets stranded in an unconfigured mail client
  • There's always an alternative way to reach out
  • The site feels more considered and intentional

If your site still uses bare mailto: links, consider this approach. It takes an afternoon to build and it removes a real friction point for your visitors.


More to read

How to invoice Apple for App Store proceeds

Apple hands you a CSV, not an invoice. Drop your App Store Connect financial report below and instantly generate the self-issued invoice and itemized report your accountant needs — one PDF, entirely in your browser.

How to check if an iOS app is installed in Expo and React Native

Use Linking.canOpenURL to detect if another app is installed on iOS in Expo. Covers Expo config plugin setup, URL scheme configuration, CNG workflow, and a hook to conditionally show a deep link button.