| Summary | Topic Preview Modal – open and interact with topics without leaving the topic list | |
| Preview | Theme Creator | |
| Repository | GitHub - VaperinaDEV/discourse-topic-preview-modal: Open a topic directly from the topic list in a native Discourse modal, read and interact with the topic, and then continue browsing the list without navigating away from it. · GitHub | |
| Install Guide | How to install a theme or theme component | |
| New to Discourse Themes? | Beginner’s guide to using Discourse Themes |
Install this theme component
Topic Preview Modal – open and interact with topics without leaving the topic list
I’ve created a new Discourse theme component called Topic Preview Modal.
The idea is fairly simple:
Open a topic directly from the topic list in a native Discourse modal, read and interact with the topic, and then continue browsing the list without navigating away from it.
It started from Facebook-style Topic Modal - Is it better? , but it ended up requiring quite a bit of integration with Discourse’s topic, post stream, composer, modal, bookmark, routing, presence, read-tracking and prefetching systems.
Why?
The normal Discourse flow is:
- You are browsing a topic list.
- You click a topic.
- Discourse navigates to
/t/.... - You read/reply/interact with the topic.
- You go back to the topic list.
For many workflows, this is perfectly fine.
However, when browsing a busy topic list, sometimes I only want to quickly inspect a topic, read a few posts, check the latest replies, react to something, or answer a quick question.
For that use case, leaving the topic list feels unnecessarily expensive.
The goal of this component was therefore to make the topic list behave more like an inbox:
topic list → preview → interact → close → continue exactly where you were.
What it does
The preview is not just a static excerpt.
It renders the actual Discourse post components inside a native DModal.
That means users can:
- read posts
- scroll through the topic
- load earlier posts
- load more posts below
- react to posts
- bookmark posts
- quote text
- reply to the topic
- reply to individual posts
- edit posts when permitted
- delete/recover posts when permitted
- flag posts
- view post history
- perform various normal post actions
- see topic presence
- follow links to other posts within the same topic
- jump directly to the relevant post
- open the full topic when needed
The intention is that the preview should feel as close as possible to actually opening the topic.
Two trigger modes
There are two ways to open the preview.
1. Entire topic-list row
This is the default.
The whole topic-list row becomes clickable, while common interactive elements such as:
- user cards
- participants
- category links
- tags
- topic status links
- bulk selection
are excluded from the modal trigger.
This makes the experience very fast when browsing a topic list.
2. Explicit expand button
Alternatively, the component can render a small expand icon through a Discourse plugin outlet. Custom themes can simple create a new <PluginOutlet /> to show the trigger.
In this mode the normal topic-list behavior remains completely untouched.
The user clicks the expand icon to open the preview, while clicking the topic title still performs the normal Discourse navigation.
This is useful if a site wants to preserve the standard topic-list interaction model.
The setting is:
trigger_style:
row
or:
trigger_style:
button
When using the button mode, the outlet is configurable as well.
The preview starts at the user’s unread position
One of the important details is that the modal does not simply load the first post.
When a topic has already been partially read, the preview calculates:
last_read_post_number + 1
and opens around that post.
So if a topic has 200 posts and the user has read through post #165, opening the preview starts around #166.
This makes the preview much more useful for real-world browsing.
It also means the component has to deal with both sides of the post stream:
- loading earlier posts when necessary
- loading newer posts below
The Earlier posts button is displayed when there are posts above the currently loaded range, while an IntersectionObserver sentinel automatically loads more posts when the user reaches the bottom.
Prefetching
One of the biggest parts of the component is its prefetch system.
The problem with a modal like this is that the user expects it to feel instantaneous.
If we only start loading the topic after the user clicks, the modal can still spend noticeable time waiting for the network.
Instead, the component can proactively prefetch topics while the user is browsing the list.
When a topic row approaches the viewport, an IntersectionObserver can schedule a prefetch.
There are several safeguards to prevent this from turning into uncontrolled background traffic.
Debouncing
A topic doesn’t immediately trigger a request just because it briefly appeared in the viewport.
The component waits for the configured debounce period.
Default:
400 ms
This is particularly useful when rapidly scrolling through a long topic list.
Root margin
Prefetching can start slightly before the topic actually enters the viewport.
Default:
50 px
This gives the request a small head start.
Concurrent request limit
The number of simultaneous prefetches is limited.
Default:
2
The setting allows between 1 and 6 concurrent prefetches.
Per-minute budget
There is also a second protection mechanism:
max_prefetches_per_minute
The default is:
15
So even if the user continues scrolling through hundreds of topics, the component won’t continuously generate speculative requests.
0 disables the limit.
Prefetch can be disabled completely
If a site doesn’t want any speculative network traffic:
enable_prefetch = false
The component continues to work normally. Topics simply load when the preview is opened.
Prefetch data is kept separate from normal topic navigation
There is an important implementation detail here.
The prefetched response is not immediately written to Discourse’s normal topic_<id> preload key.
Instead, the component uses its own namespace:
topic-preview-modal:prefetch:<topicId>
Only when the user actually opens the preview is the prefetched promise promoted to the core topic preload key.
This is intentional.
The preview may be loading a topic starting from last_read_post_number + 1, and I don’t want that preview-specific response leaking into a normal topic route navigation.
So the lifecycle is essentially:
topic enters viewport
↓
prefetch
↓
private preload storage
↓
user opens preview
↓
promote preload
↓
Topic.find()/PostStream uses the same promise
This also means the modal doesn’t have to wait for the prefetch request to finish before opening.
The modal can open immediately with its skeleton while the same promise continues resolving.
Mobile support
This was actually one of the reasons I spent considerably more time on the implementation.
The initial idea worked reasonably well on desktop, but mobile exposed several problems around:
- touch interaction
- modal scrolling
- focus
- nested menus
- the composer
- post visibility
- image loading
- performance
The final implementation therefore avoids treating the modal as a completely separate miniature forum.
Instead, it reuses as much of Discourse’s existing infrastructure as possible.
Real Discourse post components
The modal doesn’t recreate posts using a simplified custom template.
It renders Discourse’s actual:
Post
PostSmallAction
components.
This is important because otherwise the preview would quickly become a second implementation of the post UI.
The component passes the relevant actions into the normal post components, including things like:
- reply
- edit
- delete
- recover
- flag
- history
- bookmark
- wiki
- lock/unlock
- post type
- ownership changes
- badges
- hidden posts
- quoting
- etc.
The result is that the preview can behave much more like a normal topic than a traditional “preview” component.
Replies and the composer
The composer is one of the more complicated parts.
The preview can open the normal Discourse composer for:
Replying to the topic
The topic composer is opened with the topic model and the correct draft information.
Replying to a specific post
The post is passed to the composer so that the reply behaves like a normal post reply.
Quoting selected text
The component also integrates with PostTextSelection.
That means users can select text inside the preview and use Discourse’s normal quote/reply flow.
Nested modals
Another tricky part was Discourse’s modal system.
Posts can open other modals and dialogs:
- flagging
- history
- badge-related dialogs
- ownership changes
- delete confirmations
- etc.
If those were allowed to interact with the global modal service normally, opening one of them could close the entire topic preview.
To avoid that, the component creates a local sub-modal mechanism.
Conceptually:
Topic Preview Modal
│
├── Flag modal
├── History modal
├── Delete confirmation
├── Badge modal
└── other post-related modal
The preview remains mounted underneath.
The component temporarily patches the relevant modal service methods while it is active and restores them when it is destroyed.
Routing inside the modal
Another important detail is links to posts within the same topic.
For example, if a post contains a link to:
/t/my-topic/123
the preview doesn’t need to close and navigate away.
Instead, the component intercepts same-topic navigation and jumps to the requested post inside the modal.
The same applies to links targeting the topic without a specific post number.
This keeps the user inside the preview.
If the link points to a genuinely different topic, the component first restores its temporary service patches and closes itself before allowing the normal Discourse route transition.
That cleanup is important because otherwise the preview’s subscriptions and timing tracker could remain alive while the real topic route is being initialized.
Read tracking and time tracking
I also wanted the preview to behave correctly from Discourse’s perspective.
Opening a preview shouldn’t mean that read tracking is completely bypassed.
The component therefore handles:
- topic visit tracking
- visible post tracking
- topic timing
- last-read post updates
The timing tracker uses an IntersectionObserver to determine which posts are actually visible.
Every 5 seconds, visible-post timing is flushed to:
/topics/timings
When the modal closes, one final flush is performed so that the last few seconds aren’t lost.
The implementation also caps a single timing interval at 60 seconds.
Keeping the topic list’s unread state synchronized
There was another subtle issue here.
Updating Discourse’s topic tracking state alone isn’t sufficient to update the unread badge shown directly on a topic-list row.
The component therefore updates the actual topic object associated with the row after timing information has been flushed.
It updates values such as:
last_read_post_number
unread_posts
unread
new_posts
when appropriate.
This means that after reading a topic inside the modal, the topic list can immediately reflect the new read state instead of requiring a full page refresh.
Post visibility
The preview uses a shared IntersectionObserver to determine when individual posts become visible.
There is also a synchronous visibility check when the observer is attached.
This handles an edge case where a post is already visible when it is mounted, but the asynchronous first IntersectionObserver callback hasn’t fired yet.
This is especially relevant for very short topics where the entire topic may already be visible when the modal opens.
Performance considerations
A major goal was to avoid turning the modal into a performance-heavy miniature topic page.
A few things are done specifically for that.
Progressive rendering
The initial load doesn’t immediately render every post.
The component first renders enough posts to reach the target position.
The remaining posts are then rendered progressively using:
requestIdleCallback
when available, with a fallback to setTimeout.
This is particularly useful when opening a long topic around a post far down the stream.
CSS containment
Posts use:
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
This allows the browser to avoid doing unnecessary rendering work for posts that aren’t currently visible.
Lazy images
Images that haven’t already specified a loading mode are automatically given:
loading="lazy"
decoding="async"
This prevents a long topic with many images from immediately loading everything.
Loading state
The modal doesn’t just show a blank white/empty area while the request is being made.
It has a skeleton UI with:
- avatar placeholders
- username/name placeholders
- post body placeholders
- shimmer animation
The shimmer respects:
prefers-reduced-motion
so the animation is disabled for users who have requested reduced motion.
Keeping scroll position stable
There are a few places where the component needs to manipulate the scroll position manually.
For example, when loading earlier posts, the newly inserted content increases the scroll height.
Simply prepending the posts would make the user’s current position jump.
The component therefore records the previous scroll height and compensates for the difference after the posts are inserted.
This keeps the currently visible content in approximately the same place.
The same applies when jumping to a particular post.
The component performs a post-render positioning step and verifies the position again on subsequent frames to account for content that may still be settling.
Topic presence
When the relevant topic data is available, the preview can also display Discourse’s topic presence information at the bottom of the modal.
So users can see who else is currently viewing the topic without having to leave the preview.
Interaction with mobile menus and focus
Mobile introduced another category of problems.
Some Discourse UI elements use shared modal/menu services, and those services don’t necessarily know that the topic preview is currently acting as a nested browsing context.
The component therefore has additional handling around:
modal.close()- Float Kit menus
- focus restoration
- the composer
- lightbox keyboard controls
- body scroll locks
For example, if a menu internally tries to call the global modal close method, that shouldn’t accidentally close the entire topic preview.
Similarly, when the composer is open, focus needs to remain inside the composer instead of being pulled back into the preview’s focus context.
Configuration
The component currently exposes the following settings:
| Setting | Default | Description |
|---|---|---|
trigger_style |
row |
Make the entire row clickable or use an explicit button |
plugin_outlet |
topic-list-after-title |
Outlet used by the button trigger |
enable_prefetch |
true |
Enable/disable background topic prefetching |
max_concurrent_prefetches |
2 |
Maximum simultaneous prefetch requests |
prefetch_debounce_ms |
400 |
Delay before starting a prefetch |
prefetch_root_margin_px |
50 |
Start prefetching this many pixels before the row enters the viewport |
max_prefetches_per_minute |
15 |
Maximum speculative requests per minute |
The prefetch controls are intentionally configurable because different communities can have very different traffic patterns and hosting/network characteristics.
One of the main design goals: don’t break normal Discourse
I tried to keep the component as close as possible to Discourse’s existing architecture.
It doesn’t implement its own post renderer, its own composer, its own topic model or its own completely separate post stream.
Instead, it builds a temporary browsing context around Discourse’s existing components and services.
This is also why some parts of the implementation are more complicated than they might initially appear.
The more interesting challenge was:
Can a topic behave almost like a normal Discourse topic while it is actually being displayed inside another UI context?
That required dealing with the boundaries between Discourse’s global services and the local preview.


