Angular library giving you native-feeling page stacks: push/pop transitions and an interactive iOS swipe-to-go-back, without pulling in a UI framework. Built for Capacitor first; works on the web.
This is the development workspace. If you just want to use the library in your own app,
the API docs are in projects/ngx-stack/README.md.
| Path | What |
|---|---|
projects/ngx-stack |
The library. Everything publishable lives here. |
projects/demo |
Demo app: both kinds of stack, the swipe, nested stacks. |
e2e |
A Playwright script that drives a real browser through the whole surface. |
Angular 22 needs Node ^22.22.3 || ^24.15.0 || >=26. A plain node -v of 24.13.x or 24.14.x
is just below the line and the CLI will refuse to start with an unhelpful error, so this
bites easily.
There's an .nvmrc in the repo:
nvm use # reads .nvmrc → 22.22.3This only affects the current terminal tab. New tab, run it again — or set a default once:
nvm install 24 && nvm alias default 24 # any 24.15+ works
node -v > .nvmrc # point .nvmrc at it toonpm install
npm start # builds the library, then serves the demo on http://localhost:4200npm start builds the library first on purpose — see the gotcha below.
The demo forces platform: 'ios' and turns on swipeWithMouse, so both the iOS transition
and the edge drag work on a laptop. Drag from the left edge of the window with the mouse.
What's worth trying:
- Scroll the list, open a message, swipe back — the list is exactly where you left it, and the page was never unmounted.
- Type in the draft field, swipe back halfway, then drag your cursor back to the left and release. The swipe aborts, the page is still there, your text is still in it.
- Open Sheet, go two steps in, then swipe. It pops a step of the inner stack and the URL doesn't move. Swipe again from step 1 — the inner stack has nowhere left to go, so it declines and the swipe pops the sheet itself.
The demo imports ngx-stack from dist/ngx-stack, not from source — that's how the CLI wires
up library workspaces (see paths in tsconfig.json). So the library has to
be built at least once before the demo can compile, and rebuilt for every change you want the
demo to pick up.
Two tabs:
npm run watch:lib # tab 1 — rebuilds dist/ngx-stack on every change
npm run serve:demo # tab 2 — picks the rebuild up and reloadsDon't run a one-shot
ng build ngx-stackwhileng serveis up. It deletes and recreatesdist/ngx-stackwholesale, and vite's file watcher can catch the moment the directory doesn't exist — after which it wedges onCannot find module 'ngx-stack'and stays wedged until you restart it. Usewatch:lib, which writes in place.
| Command | What |
|---|---|
npm run lint |
ESLint — TypeScript, Angular, and template accessibility. |
npm run format |
Prettier, in place. format:check for CI. |
npm test |
Unit tests (Vitest) on the pure logic. |
npm run e2e |
Playwright, driving a real browser. |
The e2e suite is the real safety net. It asserts the things that are easy to get subtly wrong and impossible to see in a screenshot: that the dragged page tracks the finger 1:1, that the page underneath parallaxes at a third of the speed, that a buried page keeps its scroll offset, that an inner stack claims the edge gesture from the outer one.
It runs against the built demo, not the dev server — the demo imports the library from dist/,
and rebuilding under a running dev server races its file watcher:
npx playwright install chromium # once
npm run build:demo
npm run serve:dist & # dist/demo on :4321, with SPA fallback
npm run e2eCommits are Conventional Commits — the convention Angular itself invented — enforced by a
commit-msg hook. npm run release generates the changelog straight from them, so a commit message
that doesn't parse is a changelog entry that doesn't exist.
feat(tabs): remember each tab's URL across switches
fix(gesture): don't let a nested stack and its parent both go back
A pre-commit hook runs ESLint and Prettier over the staged files only, so committing one file
doesn't lint the whole workspace.
You choose the version number. No bot infers it, and that is deliberate: for an Angular library the
version is a claim about which Angular it supports — ngx-stack@23 means Angular 23.
Everything else is done for you. Two ways in, the same verified build out of both — nothing is ever built or published from a laptop:
A button. Actions → Release → Run workflow, type the version. Pick the branch there too: main
for the current Angular, 22.x for a fix to an old one. CI does the bump, the changelog, the commit
and the tag.
Or a tag, if you'd rather read the changelog before it leaves your machine:
npm run release -- 22.1.0 # bump, changelog, commit, tag — locally
git push --follow-tags # CI takes it from hereEither way it runs the same CI as every other push — reused as a workflow, not copied, so it can't drift — and publishes with provenance only if that goes green.
| Field | Written by |
|---|---|
projects/ngx-stack → version |
npm run release (or the button) |
projects/ngx-stack → peerDependencies |
derived from the version — the major is Angular's |
root package.json → @angular/* |
ng update |
root package.json → version |
never. It's private and stays 0.0.0. |
The peer range used to be the one thing you had to remember, at exactly the moment (a new Angular
major) when you are already busy forgetting things. It isn't a decision — under the rule it's a
restatement of the version — so it's derived, and npm run check:versions fails the build if the
two ever disagree anyway.
Every green push to main deploys the demo to GitHub Pages, release or not.
-
Secret
NPM_TOKEN— and the kind of token matters. It must be a granular access token with "bypass 2FA" enabled, or a classic automation token. Anything else is refused:npm error 403 Forbidden - PUT https://registry.npmjs.org/ngx-stack Two-factor authentication or granular access token with bypass 2fa enabled is requiredWhich is npm asking CI to type a 2FA code, something no CI can do.
GITHUB_TOKENis provided. -
Pages → Source: GitHub Actions.
A release is a chain of irreversible acts — a commit, a tag, a publish — and sooner or later one of them fails halfway through. Every step therefore asks whether it has already happened: if the tag exists, the bump is skipped; if the version is already on npm, the publish is skipped. Re-running a release that died at the publish step just publishes.
The workflow also checks out the branch's current tip rather than the SHA the run was dispatched
at, because a re-run of a half-finished release must not build on a main that has since moved.
The library's major version is the Angular major version. ngx-stack@22 is for Angular 22,
ngx-stack@23 for Angular 23. peerDependencies pin exactly one major, and there is no guessing:
npm i ngx-stack # you're on Angular 22 → you get 22.xThis is the convention across the Angular ecosystem (Material, ngx-bootstrap, PrimeNG) and it costs
something worth naming: a major bump no longer means "we broke something". Angular 23 shipping is
enough to make ngx-stack 23.0.0, even if nothing here changed. Semver purists will wince. In return,
nobody has to consult a compatibility table to find out which version they can install — and for a
library this deep in Angular's private-ish internals (RouterOutletContract, ChildrenOutletContexts,
OutletInjector), a new Angular major genuinely does tend to break something.
main tracks the newest Angular. Older ones live on maintenance branches named after their
major:
main → Angular 23 → ngx-stack 23.x → npm dist-tag: latest
22.x → Angular 22 → ngx-stack 22.x → npm dist-tag: v22-lts
21.x → Angular 21 → ngx-stack 21.x → npm dist-tag: v21-lts
CI runs on every one of them: same lint, same tests, same e2e. A fix that matters to an old Angular gets cherry-picked to its branch and released from there with its own tag.
The dist-tag is not decoration. npm publish moves latest by default, so a patch cut from 22.x
long after 23 is out would silently make ngx-stack@22 what every new npm i ngx-stack installs —
dragging people back an Angular major without a word. latest only ever moves from main.
Pinning a peer range only tells people their code is now wrong. Angular's own libraries go further:
ng update runs migration schematics that fix the consumer's code for them. That is what
schematics/migration.json is for.
ng update ngx-stack # bumps the version *and* runs every migration between old and newWhen we rename an input or move an export, the codemod goes in
projects/ngx-stack/schematics/migrations/vNN/, keyed by the version it belongs to. The CLI runs
them in order, so an app jumping 21 → 23 gets v22's migrations and then v23's.
One trap worth knowing about, because it fails silently. ng-packagr stamps
"type": "module"into the published package.json, so every.jsin the tarball is ESM — but the CLIrequire()s migrations as CommonJS. Without the siblingschematics/package.jsonsaying"type": "commonjs", the factory exports nothing,ng updateruns it, finds no function, and does nothing at all. No error. It is checked at build time here.
There is no matrix and no conditional compilation. A branch is an Angular version — its own
package.json, its own lockfile, its own node_modules. Building for Angular 22 means checking out
22.x and running the same commands you always run.
When Angular 23 comes out:
# 1. Freeze what exists. main is about to become 23; today's main is 22, so give it a home.
git checkout main
git checkout -b 22.x
git push -u origin 22.x # CI now runs on it, with its own Release PR
# 2. Move main forward.
git checkout main
npx ng update @angular/core @angular/cling update bumps the workspace's own Angular. Then widen the peer range, and add a migration for
anyone who has to come with you:
projects/ngx-stack/schematics/migrations/v23/ ← codemods, if the API moved
projects/ngx-stack/schematics/migration.json ← register them against "23.0.0"
Fix whatever the new Angular broke — for a library this deep in RouterOutletContract and
ChildrenOutletContexts, expect something — and get CI green. Then cut it:
npm run release -- 23.0.0
git push --follow-tagsnpm run release refuses to run if the three numbers disagree, so you cannot forget the peer range.
The normal case, and the whole reason the branches exist.
# 1. Fix it on main, where the newest Angular lives.
git checkout main
git commit -m "fix(gesture): don't arm inside a horizontal scroller"
git push
# 2. Ship it to everyone on Angular 23.
npm run release -- 23.0.1
git push --follow-tags # → npm dist-tag: latest
# 3. Take the same commit back to Angular 22.
git checkout 22.x
git cherry-pick <the fix>
git push # CI runs on 22.x too — same lint, tests, e2e
# 4. Ship it to everyone still on Angular 22.
npm run release -- 22.0.1
git push --follow-tags # → npm dist-tag: v22-ltsTwo releases, two tags, two npm versions. Nothing you do on one branch can touch the other.
Why the dist-tag matters, concretely. After step 4, npm holds 23.0.1 (tagged latest) and
22.0.1 (tagged v22-lts). And yet:
| Who | What they get | Why |
|---|---|---|
An app on Angular 22, with "ngx-stack": "^22.0.0" |
22.0.1 | Their semver range resolves to it. The dist-tag is irrelevant. |
Someone typing npm i ngx-stack fresh |
23.0.1 | A bare install follows latest. |
That is exactly the outcome you want, and it depends entirely on 22.0.1 not being published as
latest. Publish it plainly and every new project silently installs an Angular-22 package. No error,
no warning. That one line in scripts/version.mjs is doing all the work, and
it is why it asks the registry what latest currently is instead of trusting a branch name.
What can't go wrong: cutting 23.0.1 from the 22.x branch. npm run release runs
check:versions, which knows the branch it is standing on and refuses.
git checkout 22.x
git cherry-pick <the fix from main>
npm run release -- 22.0.1
git push --follow-tagsIt publishes under the v22-lts dist-tag, so latest stays on main. Anyone on Angular 22 keeps
getting it from npm i ngx-stack — their ^22 range resolves to it — without new projects being
dragged backwards.
What must never happen on a maintenance branch: a release with the wrong major. Cutting 23.0.0
from 22.x would publish a version belonging to another branch, over the top of it.
npm run check:versions knows the branch it is on and fails first, so you would have to ignore red
CI to manage it.
Everything above collapses to this. There is nothing else to remember:
npm run release -- 23.0.0 # new Angular, or a fix — same command either way
git push --follow-tagsThe rest is machinery that only speaks up when you're about to do something wrong: the release refuses a dirty tree, refuses a version that disagrees with the peer range, refuses a major cut from the wrong branch — and CI refuses a tag that disagrees with the package, and works out the npm dist-tag by asking the registry rather than trusting anyone's branch name.
projects/ngx-stack/package.json version 22.0.0 ← what we publish
projects/ngx-stack/package.json peerDependencies ^22.0.0 ← what we claim to support
package.json dependencies ^22.0.0 ← what we actually build against
Nothing in Angular's toolchain ties these together. ng update will happily bump the third and leave
the other two behind, and the result is a package that lies about what it works with — which npm will
install for someone without a word. npm run check:versions runs first in CI for that reason, and it
knows about the branch too.