DEV Community

Cover image for Building ng-beacon: a lightweight guided tour library for Angular
HomelessCoder
HomelessCoder

Posted on

Building ng-beacon: a lightweight guided tour library for Angular

I was working on Omnismith and needed a decent product tour. That sounded like a solved problem, so I went looking for an Angular package I could install and forget about.

There were usable options. Some were solid. But none fit the shape I wanted for this app: no manual scripts or stylesheets, no generic abstraction I had to translate back into Angular, and nothing that felt awkward in a zoneless Signals-based app.

So I built one.

It's called ng-beacon: a lightweight guided tour library for Angular 19+, built around Signals, SVG spotlight overlays, and a deliberately small setup.

This post is less about “I published a package” and more about the constraints that shaped it, which parts turned out to matter, and how the API changed once it had to survive real application code.

The Constraint Was Simplicity

I didn't want a tour system that became its own mini-framework. I wanted something that looked roughly like this:

provideBeacon()
Enter fullscreen mode Exit fullscreen mode

and later:

this.beaconService.start(STEPS)
Enter fullscreen mode Exit fullscreen mode

That was the baseline: no Angular Material dependency, no CDK requirement, no big configuration surface, no assumptions about which translation library a consumer uses, and no architectural gravity that would make the feature feel more expensive than it should.

There are good generic tour libraries. I just didn't want to adapt a generic engine into Angular-shaped application code if I could avoid it. That constraint ended up shaping almost every decision.

The Parts That Were Actually Fun to Build

https://github.com/HomelessCoder/ng-beacon | Demo GIF

I started with an SVG spotlight overlay because it gave me clean geometry and smooth control over the cutout. That part worked early. The more interesting problems were everything around it.

1. Click blocking around the spotlight

The SVG handled the visual spotlight nicely, but I still wanted the interaction model to be explicit.

The highlighted area may need to stay interactive while the rest of the screen still behaves like an overlay. I could have pushed all of that into the SVG layer, but I preferred to separate the visual layer from the interaction layer.

So I used dynamic click-blocking divs around the spotlight rectangle. Instead of one full-screen blocker doing everything, the page gets four blocker regions around the highlighted area.

It is not the only way to solve it, but it made pointer behavior much more explicit and predictable.

2. Tooltip positioning that reacts to real size

The tooltip height changes from step to step. Position changes too. Which means you can't just hardcode offsets and hope for the best.

So the positioning logic measures the tooltip, calculates where it wants to be, and then clamps it to the viewport edges. That sounds small on paper, but it is the difference between “works in the demo” and “feels stable while resizing the window and moving between steps.”

3. Iterating on the motion until it stopped feeling mechanical

I also spent time on the transitions. Nothing dramatic. Just enough to make the spotlight, blockers, and tooltip move like they belong to the same system instead of three separate UI layers reacting independently.

That polish mattered more than I expected.

The First API Was Too Simple

My first API was basically this:

start(steps: BeaconStep[])
Enter fullscreen mode Exit fullscreen mode

And for simple cases, that is still fine. But real application code pushed it pretty quickly. Some tour steps only make sense while a specific component exists, so the library needed a way for components to contribute their own steps instead of keeping everything in one central list.

That led to registerTourSteps() and, from there, to startContextTour().

private readonly _tour = registerTourSteps(DASHBOARD_STEPS);

this.beaconService.startContextTour();
Enter fullscreen mode Exit fullscreen mode

At that point the API made more sense. Components owned their local tour context, and the running tour could stay aligned with the actual UI. If a component disappeared during navigation, its steps disappeared with it.

start(steps) stayed as the simple option. startContextTour() became the better option for tours that need to follow real application state.

Internationalization Without Coupling

Omnismith supports eight languages, so I knew very early that hardcoded English strings inside the tour UI were not going to survive long.

At the same time, I didn't want the library coupled to a specific translation library.

In Omnismith I use ngx-translate. Someone else might use Transloco. Someone else might want their own function. So instead of picking a winner, I exposed a helper:

provideBeaconTranslateFn(() => {
  const translate = inject(TranslateService);
  return (key: string) => translate.instant(key);
})
Enter fullscreen mode Exit fullscreen mode

That handles both step content and built-in UI labels like close / next / previous. Consumers get flexibility without the package forcing one i18n stack on them.

Tests Helped Me Stop Guessing

I added tests with AI assistance. What mattered was covering the behavior that was easy to break while iterating:

  • step filtering
  • navigation behavior
  • focus movement and restoration
  • overlay rendering
  • tooltip positioning branches
  • label translation and configuration

Once those were in place, I could keep refining the code without mentally re-running the same manual checks every time.

What the Final Result Looks Like

The setup stayed small, which was the whole point.

import { provideBeacon } from 'ng-beacon';

export const appConfig = {
  providers: [provideBeacon()],
};
Enter fullscreen mode Exit fullscreen mode
@if (beaconService.isActive()) {
  <beacon-overlay />
}
Enter fullscreen mode Exit fullscreen mode
// Direct tour
this.beaconService.start(MY_TOUR);

// Component-scoped tour
this.beaconService.startContextTour();
Enter fullscreen mode Exit fullscreen mode

And if you need the more structured path, that is there too with registerTourSteps, startContextTour, and provideBeaconTranslateFn.

It also has two small lifecycle events now, finished and dismissed, which turned out to be useful for simple tracking and analytics.

That mattered to me more than I expected. I didn't just want a tour overlay. I wanted something that fit naturally into Angular application code, including translation, component ownership, reactive lifecycle handling, and app-level integration points.

That was the real target from the start: simple by default, but not boxed in.

A Few Things I Learned

Small libraries need stronger constraints, not fewer. If you don't decide what to exclude, they become messy surprisingly fast.

The API you start with is rarely the API you actually need. start(BeaconStep[]) was fine until the app got bigger, tour context became local, and components started destroying themselves mid-tour.

Framework-agnostic integration points are usually worth it. The translation hook is a good example. Coupling would have been easier in the short term and worse for everyone else.

Polish matters more than feature count for UI infrastructure. Smooth motion, stable positioning, and clean focus behavior are what make a tour feel trustworthy.

If you're building Angular apps and want something that feels more native to Angular application structure than a generic wrapper, maybe this is useful.

This is my first public TypeScript / Angular library, which makes it more significant to me than a normal internal extraction. Not because it's a giant technical achievement. Just because shipping a public package feels different. It's one thing to write code for your own app. It's another to write something other people might install and judge in five minutes.

If nothing else, building it reminded me that sometimes the right answer to “surely a package already exists for this” is “yes, but not one I actually want to use.”

👉 npm: ng-beacon
👉 GitHub: HomelessCoder/ng-beacon

If you try it and something feels awkward, I'd genuinely rather hear that than a polite “looks nice.” That's the only way these packages get better.

Top comments (0)