Notes from building an AI design collaborator · part 14
Every modal in our app shared one URL. Our analytics couldn't tell them apart.
· 6 min read
Our product runs a lot of its life inside modals. Open a payment, reconcile a deposit, add a bank account, half the real work happens in a dialog that pops over the page. And every single one of those modals lived at the same URL as the page behind it.
Which meant our analytics were blind to all of it. Every modal open looked like the user just sitting on the dashboard. We had a busy, important part of the product that, as far as our data was concerned, didn't exist.
Give the modal a URL
The fix sounds trivial. Every modal gets a hash fragment on the end of the address, so opening the
transfer modal takes you from /payments to /payments#modal=create_transfer. One registry of
names, snake case, one name per surface. Ours has 124 entries in it, which was itself a useful
thing to learn about our own product.
Now the modal is a thing you can point at, filter on, build a funnel from. A dialog that used to be invisible becomes a first-class, measurable surface. And because it's in the address bar, you can link someone straight to it.
Simple idea. The gotchas are where it got interesting, and where I'd save you some time. We use PostHog, so the specifics below are theirs, but the shape of each problem is not.
Check whether the event fires. Don't assume either way.
The first thing that happened is that we added the tags, opened a modal, and no pageview fired at all. Analytics libraries generally treat a hash change as "same page, nothing happened," because historically it meant jumping to an anchor. So opening a modal recorded nothing, and the whole scheme looked broken on day one.
The fix is to tell the library that this counts as a view: listen for the hash changing and capture a pageview yourself.
Now the part I got wrong, and the reason this section isn't just "add a listener." Months later I went to check the data and modal opens were being captured perfectly, thousands a day, more than half of all our pageviews carrying a modal tag. Nobody had added the listener I'd specified. Our shared analytics package already did exactly this, and had for ages: it listens for hash changes and patches the history methods, then fires the pageview.
Which means if I'd followed my own advice I'd have added a second listener and silently doubled every modal number in the company.
So the instruction is not "add a listener." It's open a modal, look at your live events, and find out. Then add one only if nothing arrives. This is a five-minute check and it is the difference between instrumenting your product and corrupting it.
Make the tag replace, not stack
The second one was quieter and nastier. Our tags accumulated. Open one modal, then another, and
instead of replacing the first tag the URL kept both, then three, then a growing pile, with only
the first one carrying the actual #.
A funnel built on those URLs shows people entering step one and then apparently vanishing, because by step two the address is some compound thing that matches nothing you filtered for. A clean-looking chart showing catastrophic drop-off, and it was a measurement bug, not a user problem. That's the sort of thing that gets a real feature killed in a review by someone who trusts the chart.
Replacing rather than appending fixes the common case. But some of our modals can only open on top
of another one, and for those the parent genuinely has to stay in the address or the child can't
render or be linked to. So the honest model isn't one name, it's a stack rendered as a path:
#modal=parent/child. Push a segment when a modal opens, pop it when it closes, clear the whole
thing when the last one goes. The registry marks each modal as standalone or as requiring a parent,
a deep link validates the chain before opening anything, and an orphaned child link does nothing
rather than rendering something broken.
That gives you three things worth having, all derivable from one string: the focused modal is the last segment, the flow someone entered through is the first, and "was this modal on screen at all" is a containment check.
Turn the string into properties before anyone builds a chart on it
This is the step I'd most want to hand over, because skipping it is what makes the whole scheme technically correct and practically unusable.
A compound URL is a string. Your teammates cannot build a funnel out of a string with regular
expressions in it, and if they try, they will get the # versus & distinction wrong, or they'll
match create_invoice and quietly catch create_invoice_step_2 along with it.
So parse it once, in the hook your analytics library gives you for editing events on the way out, and emit real properties: the focused modal, the entry modal, the full stack. Three fields anybody can pick from a dropdown. Historical data can be backfilled with a query at read time, so this doesn't have to be perfect from day one.
Two more, smaller:
Filter with "contains," never "equals." Contains survives moving between test and production, and it tolerates the tag not being where you expected in the string.
For funnels, prefer a dedicated event over a pageview. A modal_opened event with the modal
name as a property is exactly one per open, whereas hash-change pageviews fire on close as well as
open, so you'll be reasoning about roughly double what you think.
The one that surprised me most
Folding the modal tag into the path makes modals show up properly in your pages report. It also, combined with entity IDs already sitting in our URLs, produced 3,824 distinct paths in a fortnight, which makes that report meaningless in a different way.
The instinct is to clean the paths up. Do not do that first. Until the modal name exists as its own property, the tag in the path is the only thing identifying which modal opened, and stripping it blinds every modal surface at once. Normalize into properties first, clean the paths second. That ordering cost us nothing only because somebody caught it in review.
There were smaller ones too. A modal whose tag didn't match its name, so you'd filter for the thing and get nothing. Modals with no tag at all, so they stayed invisible. And our analytics toolbar keeps its own auth token in the URL fragment, which is the same place the modal tag lives, so the two can collide and log you out of the toolbar mid-session.
The one line I'd tattoo on this: if you can't address it with a URL, you can't measure it, and you can't link to it. Modals, wizard steps, side panels, anything that changes what the user is looking at deserves its own address.
Just go in knowing the tooling was mostly built for pages that fully reload, and modals aren't that. Verify what your app already does before adding anything. Make the tag replace rather than stack. Turn it into properties before anyone builds on it. And treat any funnel that shows a perfect cliff with suspicion, because the most confident-looking chart is often the one measuring your instrumentation instead of your users.
Part of a series on building an AI collaborator for our design team at Xflow. Each post stands on its own.
- analytics
- product-design
The work behind the series
Designing the Instructions
This post is one thread out of a three-month project: an AI design collaborator for a payments team. The case study is the whole of it: what worked, what broke, and what is still unproven.
Read the case study →