Twitter-style modals in Vue 3 / Vue Router 4
(Yes, I still call it Twitter)
I was looking to create Twitter-style modals in Vue 3. In short, these are modals that appear on top of a main view, but have their own route. Therefore, the user can use the Back button of the browser to dismiss the modal, and they can also navigate to the modal content via the direct URL.
Back in the day, Twitter used to do these for each tweet — a tweet would open as a modal on top of your timeline; and the URL would change to reflect the fact that you’re viewing a particular tweet. Hitting “Back” would simply close the modal, revealing the timeline beneath. Navigating to the tweet URL from an external source (or refreshing the tab) would then display the tweet as an ordinary page.
These days, I’ve noticed they only do this style of modal for the “Post” button in the sidebar, but the principle is the same: when the tweet composer modal is opened, the user is navigated from their current page to /compose/tweet
while the previous page is still visible behind the modal.
Prior art
While learning Vue 3 (and the corresponding Vue Router 4), I wanted to implement this style of modal. There is a long-running GitHub issue requesting this exact thing to be an official feature or a documented pattern, but having been opened in 2016, and then closed “in favour” of another long-standing issue that’s still open, I don’t expect anything official to be implemented any time soon.
The extensive issue comment history, as well as the Internet more widely, include multiple proposed solutions for this.
While I felt that this style of interaction is something that should be very easy to implement, the impression I got was that it was simply too much to ask from Vue. Many of the solutions I found seemed somewhat hacky, fragile, or workaround-y. Many were entire projects in their own right, and it was hard to tell what’s part of the technique, and what’s part of the demo.
Crucially, many of these solutions assume that the modal must be the direct child of a given route:
const routes = [
{
path: '/',
component: () => import('Main.vue'),
children: [
{
path: '/modal',
component: () => import('Modal.vue')
}
],
},
{
path: '/other',
component: () => import('Other.vue'),
},
];
Regardless of the implementation of actually making it look like a modal, this approach will always have the side-effect that if there is a router-link
in Other.vue
that directs the user to /modal
, the underlying main view will also change to be the modal’s parent (in this case Main.vue
). This seems undesirable to me.
Some of the approaches I saw also meant that dynamic importing of components is no longer possible, which is also a major drawback for me.
What’s a “Twitter-style modal”, anyway?
There are five crucial points that define what (to me) is a Twitter-style modal in Vue 3:
- The modal has its own dedicated route, which acts as a normal part of the browser history.
- Therefore, the modal can be opened (and closed) by ordinary
router-link
s navigating to and from the modal’s route. - The modal is navigable from the components of multiple different routes, without being directly related to any of them.
- Navigating to the modal keeps the pre-existing underlying main view as-is, and preserves its state.
- The modal can use dynamically imported components.
The behaviour that Twitter (used to) have, where tweet modals become full-on tweet pages when the same URL is accessed externally, is not important to me — but it’s ultimately a matter of CSS, not routing logic.
Many of the demos and code samples I saw did not satisfy points 4 and 5, and some failed point 3. None of the ones I saw satisfied all five. It’s possible that the perfect solution was already proposed somewhere in the multitude of related GitHub issues, discussions, RFCs, and demo repos — but I, at least, did not find something suitable for my purposes.
Working it out
If we leave the hacks aside, and just use vanilla Vue 3 + Vue Router 4, we can pretty much satisfy points 1, 2, and 5 immediately.
And the simplest solution to satisfy point 3, of course, is to have the modal route as a sibling of any other route:
const routes = [
{
path: '/',
component: () => import('Main.vue'),
},
{
path: '/other',
component: () => import('Other.vue'),
},
{
path: '/modal',
component: () => import('Modal.vue')
},
];
Wow, 80% of the way there already!
The friction comes from point 4 — displaying this route as a modal on top of (and not instead of) the component the user is navigating from.
Maybe we can solve it with a separate router-view
that’s dedicated to modals:
<router-view></router-view>
<router-view name="modal"></router-view>
const routes = [
{
path: '/',
component: () => import('Main.vue'),
},
{
path: '/other',
component: () => import('Other.vue'),
},
{
path: '/modal',
components: {
modal: () => import('Modal.vue')
},
},
];
We’ll just assume for simplicity that Modal.vue
will take care of the CSS and other fanciness to visually display the modal as a modal, floating on top of the main view.
This displays the Modal.vue
component in the modal
view — but the problem is that since the modal route doesn’t define anything for the default
view, Vue clears it. Now the modal is floating on top of nothing.
Keeping the existing view
So, we’ll need to find a way to keep the main view untouched.
Simplest way I could think of is by creating a per-route navigation guard, that modifies the modal route the user is navigating to, by injecting the default
component from the route the user is navigating from.
function keepDefaultView(to, from) {
to.matched[0].components.default = from.matched[0].components.default;
}
const routes = [
{
path: '/',
component: () => import('Main.vue'),
},
{
path: '/other',
component: () => import('Other.vue'),
},
{
path: '/modal',
components: {
modal: () => import('Modal.vue')
},
beforeEnter: [keepDefaultView],
},
];
This means that if the user navigates to /modal
from /other
, then Other.vue
is injected as the default
component in the modal route. Now the main view is preserved.
Only one problem: if the user navigates to /modal
directly (i.e. from an external link, or refreshing the tab), the from
parameter will not have anything useful in it, and the code will throw an error:
This is because from.matched
will be an empty array, since the user didn’t “come from” anywhere as far as the Vue Router is concerned.
So, we’ll need to define a fallback component — something for Vue to load for the default
view if the user comes in fresh:
function keepDefaultView(to, from) {
if (from.matched.length) {
to.matched[0].components.default = from.matched[0].components.default;
} else {
to.matched[0].components.default = () => import('Main.vue');
}
}
Now, if the user refreshes the tab, or arrives to /modal
from a bookmark or something, the modal will be displayed on top of Main.vue
.
Homework: Since the fallback component is imported dynamically, it could be possible to configure each route to define the most natural fallback component. For example, a user settings modal would probably want to load a user profile component in the main view as a fallback. How could we modify
keepDefaultView
to support routes defining their own fallbacks?
Stayin’ alive
For my app, there’s one problem remaining: state is not retained. If the user accidentally navigates away from the modal (I do this accidentally all the time because of the left swipe gesture on Mac), any state the modal had will be reset. If the modal happens to contain a particularly long or complex form, this will be infuriating to your user.
Fortunately for this, there is a pre-existing pattern in Vue Router: using KeepAlive
together with the router view slot:
<router-view></router-view>
<router-view v-slot="{ Component }" name="modal">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
Now, if the user navigates away from the modal accidentally, they can just press their browser’s Back button (or Forward button, whichever is the case), to get back to the modal, and their form state will still be there.
The default
view does not need KeepAlive
for this particular interaction, since — thanks to our keepDefaultView
navigation guard — the component is not destroyed when the modal is opened.
Using the slot method also allows us to dress up the modal:
<router-view></router-view>
<router-view v-slot="{ Component }" name="modal">
<div class="modal-container" :class="{ 'is-active': Component }">
<div class="modal">
<keep-alive>
<component :is="Component" />
</keep-alive>
</div>
</div>
</router-view>
Here, the modal router view will handle the modal container markup and styling. We can even apply a class (e.g. is-active
) to the container based on whether the modal view currently has a component loaded (as in, the user has navigated to /modal
), or not. This allows the main App.vue
to hide and show the modal based on the class, and also apply any CSS transitions that you might want.
Additionally, now the component that’s loaded in the modal doesn’t need to actually care whether it’s being displayed inside a modal, or as a full page — when the modal styling is handled centrally, the component can concentrate on its own unique functionality, regardless of the presentation context.
Homework: The original Twitter-style modals displayed as a full page when the user navigated to the modal URL directly. We already know that the
from
parameter in the navigation guard will be fairly blank when the user does that. How can we modify the guard and the view to display the modal component as a page when accessed directly?
Wrapping up
We now have a Twitter-style modal in Vue 3, which has its own navigable route, while keeping the main view alive underneath — and we never had to give up dynamic importing, either. All five points have been duly satisfied.
You could argue that this solution is nothing special, and simply using all the documented functionality of Vue Router 4 as described in the docs — but this type of modal behaviour does seem to be a commonly searched-for pattern, and as far as I could see, the documentation currently doesn’t offer it as an example use case anywhere.
Since it wasn’t in the docs, I came to think that this pattern was antithetical to how Vue or its router is supposed to work — and the extent to which some of the examples online were hacking router logic, or defining extensive custom components, seemed to confirm that idea. This felt like something that wasn’t supposed to be done.
In the end, I was surprised how simple it turned out to be — like most things in Vue tend to be. But in part, I think it also goes to show you how software documentation is not just about technical specification and individual atomic features. Providing an abundance of examples of how the different features come together to form commonly used patterns doesn’t just help users, but examples (or a lack thereof) can rightly or wrongly form fundamental impressions of what your software can and can’t even do in the first place.