@arclux/glint
v1.1.2
Published
Scroll reveal that cannot hide your content. Zero dependencies, reduced-motion by default, CSS scroll-driven animation as an enhancement tier.
Maintainers
Readme
@arclux/glint
Scroll reveal that cannot hide your content. Zero dependencies, reduced motion honoured by default, CSS scroll-driven animation as an enhancement tier.
npm install @arclux/glint<div data-reveal="fade-up">…</div>import { init } from '@arclux/glint';
import '@arclux/glint/glint.css'; // optional
init();The premise
The revealed state is the default. Nothing is hidden in the stylesheet. The hidden state is applied at runtime by the code that has already committed to removing it.
Libraries that hide in CSS and un-hide in JS go blank on a script error, a
blocked CDN or a 404'd bundle. <noscript> does not cover that — it fires when
JS is disabled, not when it fails. Every path in glint's engine ends with the
element visible.
Support
CSS scroll-driven animations are not Baseline. Firefox has blocked them since September 2025 and they remain flag-only in 152 (June 2026).
| Engine | animation-timeline: view() | animation-trigger |
| ------------- | ---------------------------- | ------------------- |
| Chrome / Edge | 115+ | 145/146+ |
| Safari | 26+ | — |
| Firefox | flag only | — |
The two are not interchangeable. view() is scrubbed — progress binds to
scroll position and reverses when you scroll up. animation-trigger is
fired — a normal animation that starts at a threshold and runs on its own
clock. The second is the reveal semantic, and it is Chrome-only. So JS remains
the guaranteed path, and when animation-trigger reaches two engines the JS
tier becomes deletable rather than rewritable.
Tiers
| | No modern CSS | Modern CSS | | --------- | ----------------------- | ------------------------------- | | No JS | content visible, plain | CSS scroll-driven reveal | | JS | JS reveal (common path) | JS reveal; CSS tier stands down |
The JS engine stamps data-glint-engine on what it claims, and every CSS-tier
rule is :not([data-glint-engine], [data-glint-state='revealed']), so the two
never fight.
The stamp's value identifies the instance that made the claim, so a second
init() can tell a claim held by a running engine from one left behind by a
destroyed one — the second is adopted rather than avoided, which is what stops
DOM restored from a framework's cache staying hidden forever. Only its presence
is ever styled on; treat the value as opaque.
Hiding inside @supports (animation-timeline: view()) is safe where hiding in a
plain rule is not: that block only exists in engines that perform the reveal
themselves. If the timeline is inactive, the animation does not apply and the
element renders revealed.
mode: 'scrub' is CSS-only by design — scrubbing from a scroll handler is worse
than what the browser does natively. Where the engine lacks it, scrub elements
reveal once like everything else.
Failure modes
Each row is a test.
| | Behaviour |
| --------------------------- | ------------------------------------- |
| JS never loads | all content visible |
| JS throws during init | all content visible, error logged |
| JS loads late | visible, then animates — never blank |
| Element can never intersect | revealed after timeout (default 3s) |
| Print | everything revealed |
| bfcache restore | revealed elements stay revealed |
| Anchor link into mid-page | on-screen elements appear, no cascade |
| Two init() calls | second claims nothing already owned |
| Unknown preset name | revealed, not hidden; warns once |
| Soft navigation away | pending elements released, not leaked |
| Cached DOM restored | stamp from a dead engine is adopted |
| arm() with no glint after | pre-hide expires, page readable |
| Un-upgraded custom element | entrance waits for the definition |
The fourth row is the one that produces permanently invisible content
elsewhere. Arming a timeout at observe time would force-reveal a whole long
page 3s after load, so it arms on proximity — but IntersectionObserver clips a
target by its ancestors' overflow, so an element inside overflow: hidden never
reports as near however large the rootMargin. glint watches the nearest
clipping ancestor instead. Elements with no layout box at all are detected
from their zero-area geometry and start the clock immediately.
Proximity is always measured against the viewport, even when you configure a
custom root. One consequence is worth knowing: content inside a scrolling
panel that is itself on screen will reveal at the ceiling if the reader never
scrolls that panel. That is the deliberate trade — a sidebar or drawer nobody
opens is the classic way content goes permanently invisible. Set timeout: 0
to opt out.
Accessibility
prefers-reduced-motion: reduceis respected by default.- Reduced motion means reveal instantly, never skip the reveal.
- The query is watched at runtime, not read once at init.
- Tabbing into a pending element reveals it instantly — focus never lands on something invisible.
- Kill switch:
<html data-reveal-disabled>orinit({ disabled: true }). Live — setting it settles the page immediately.
API
<div data-reveal="fade-up" data-reveal-delay="200">…</div>
<ul data-reveal-stagger="60">
<li data-reveal="fade-up">…</li>
<li data-reveal="fade-up">…</li>
</ul>Data attributes are the API. Reveals apply to arbitrary content, so a wrapper component is never required.
| Option | Default | Notes |
| ------------------ | ------------------ | ------------------------------------------ |
| mode | once | once | repeat | scrub |
| preset | fade-from-below | see below |
| duration | 500 | ms, or a CSS time |
| delay | 0 | ms, or a CSS time |
| easing | out | a name, or any CSS easing incl. linear() |
| distance | 24px | any CSS length |
| threshold | 0.2 | or some / all |
| margin | 0px 0px -10% 0px | root margin |
| stagger | 0 | ms between siblings |
| root | viewport | element or selector |
| timeout | 3000 | forced-reveal ceiling; 0 disables |
| selector | [data-reveal] | what to claim |
| engine | auto | auto | js | css |
| observeMutations | false | a real cost; off by default |
| disabled | false | reveal everything, animate nothing |
Precedence, lowest first: defaults → CSS custom properties
(--reveal-duration, --reveal-easing, --reveal-distance, --reveal-delay,
--reveal-stagger) → init() options → data-reveal-* attributes. Custom
properties sit below init() because they are the theme layer; a call site
passing an explicit option is being more specific than the theme.
const glint = init();
glint.refresh(); // claim new content
glint.refresh(shadowRoot); // …including inside a shadow root
glint.reveal(el); // reveal one element now, no animation
glint.revealAll(); // reveal everything outstanding
glint.destroy(); // stop, revealing anything still pendingglint.elements lists elements still pending. Revealed ones are released, not
retained.
The instance type is exported as GlintController, so consumer code never
needs ReturnType<typeof init>:
import { init, type GlintController } from '@arclux/glint';
let glint: GlintController | null = null;Presets
Named for where the element comes from, not where it travels.
| Name | Alias | Starts |
| ----------------- | ------------ | -------------------------- |
| fade | fade-in | in place |
| fade-from-below | fade-up | below, rises |
| fade-from-above | fade-down | above, drops |
| fade-from-left | fade-left | left of place, moves right |
| fade-from-right | fade-right | right of place, moves left |
| fade-from-start | fade-start | the inline start edge |
| fade-from-end | fade-end | the inline end edge |
fade-from-start / fade-from-end are the logical pair — they follow writing
direction and mirror under RTL with no second stylesheet.
@arclux/glint/full adds zoom-in, zoom-out, rise-from-below, blur-in,
blur-from-below, the slide-from-* set (travel, no fade), flip-from-below,
flip-from-above, rotate-in, and wipe-from-below / wipe-from-above.
Motion tokens
:root {
--reveal-duration: var(--duration-enter, 500ms);
--reveal-easing: var(--ease-out, cubic-bezier(0.16, 1, 0.3, 1));
}Loaded alongside a token tree that defines --duration-enter and --ease-out
— such as ARC UI's — glint follows it. The literals are
fallbacks, so it works standalone.
Springs
No runtime physics. spring() samples a damped oscillator once into a CSS
linear() string, which works in WAAPI, a stylesheet and the CSS tier alike.
import { spring } from '@arclux/glint/easing';
const { easing, duration } = spring({ stiffness: 220, damping: 18 });
init({ easing, duration });Stagger
Scoped to the batch of elements that arrive together, not to index in the list. Six items entering at once cascade; six entering one at a time as the reader scrolls each animate immediately. Otherwise the bottom of a long list waits seconds for its turn.
The group is the nearest ancestor with data-reveal-stagger, falling back to
the parent.
SSR
The server renders the finished content. There is nothing to reconcile.
init()is safe before or after hydration, and safe to call twice.- Call
refresh()for inserted content, or passobserveMutations: true. content-visibility: autogives skipped contents no layout box, so glint starts the forced-reveal clock on them.
Soft navigation
Frameworks that swap the document without a reload replace the nodes glint is
observing. Pending elements that leave the document are released automatically
— on the next refresh(), or straight away with observeMutations: true — so
a missing teardown is no longer a leak. Tearing down explicitly is still
better: destroy() settles the outgoing page in one pass instead of leaving it
to the sweep, and for a framework that caches DOM it is required (see Turbo
below). init() is idempotent, so the pairing is safe in either order.
Astro view transitions — astro:page-load covers the first load as well as
every swap, so this is the whole integration:
import { init } from '@arclux/glint';
let glint;
document.addEventListener('astro:before-swap', () => glint?.destroy());
document.addEventListener('astro:page-load', () => (glint = init()));Turbo is the same shape but hangs off a different pair of events, and the choice is not cosmetic:
document.addEventListener('turbo:before-cache', () => glint?.destroy());
document.addEventListener('turbo:before-render', () => arm());
document.addEventListener('turbo:load', () => (glint = init()));turbo:before-cache, not turbo:before-render. Turbo caches a clone of
the page for back-navigation, and it takes that snapshot before it renders the
next page. Destroy any later and the snapshot preserves whatever was pending at
the time — inline opacity: 0, data-glint-state="pending" and a
data-glint-engine stamp belonging to an instance that no longer exists.
Restoring that copy is survivable: a fresh init() recognises the stamp as
dead, adopts the element and reveals it on schedule. But it is a reveal the
reader has already seen, on content that was finished when it was cached.
Destroying first means the snapshot is of a settled page, and the restored copy
is plain visible content with nothing to redo.
htmx replaces fragments rather than the document, so it needs no teardown —
call glint.refresh() on htmx:afterSwap, or pass observeMutations: true
and let it claim inserted content on its own.
armSnippet() runs once, on the full page load, so a soft navigation would
otherwise leave the entry page flash-free and every page after it not. arm()
is the runtime half — call it immediately before the swap and the content the
router parses next is hidden before it paints:
import { init, arm } from '@arclux/glint';
let glint;
document.addEventListener('astro:before-swap', () => {
arm();
glint?.destroy();
});
document.addEventListener('astro:page-load', () => (glint = init()));refresh() and init() both disarm once the JS tier has taken the hidden
state over, so nothing has to remember to. And arm() keeps the same
self-expiry as the snippet: if the navigation is abandoned, or glint is
destroyed and never restarted, the pre-hide releases on its own. Nothing that
hides content is allowed to depend on a later call arriving.
The first-frame flash
Because nothing is hidden until the script runs, an element can paint visible for a frame. The default accepts that; the alternative is a hidden state the stylesheet cannot undo.
To remove it, inline armSnippet() in <head>:
import { armSnippet } from '@arclux/glint';
`<script>${armSnippet()}</script>`;It arms a pre-hide rule and sets a timer to release it. If glint never loads, the page becomes readable on its own. That expiry is the point.
<head> is the natural home, but the snippet only needs to run before the
elements it covers are parsed: first child of <body> works too, and the
rule it arms is set on <html>, which exists by then. That placement is the
one to reach for when the head belongs to a shared layout — an Astro or Nunjucks
page can arm its own reveals without editing the site-wide template.
Packaging
ESM only, types shipped, side-effect-free except init().
| Entry | Contents |
| ------------------------- | --------------------------------------- |
| @arclux/glint | init(), arm(), fade and slide |
| @arclux/glint/full | every preset |
| @arclux/glint/auto | auto-init on DOM ready, window.glint |
| @arclux/glint/element | registers <arc-reveal> |
| @arclux/glint/easing | EASINGS, spring() |
| @arclux/glint/styles | constructable stylesheet for shadow DOM |
| @arclux/glint/glint.css | CSS tier, print and kill-switch guards |
No build step:
<script type="module" src="/vendor/glint/auto.js" data-glint='{"stagger":60}'></script><arc-reveal> wraps the same behaviour as an element. It has no shadow root and
is not a Lit element, so children stay light DOM and the server renders finished
content with no hydration step.
<arc-reveal preset="fade-from-below" delay="200">…</arc-reveal>Reveals on custom elements
Putting data-reveal directly on a custom element is fine, and glint waits for
that element to upgrade before it plays the entrance.
The wait is not politeness. The usual companion to a custom element is a FOUC
guard — my-thing:not(:defined) { opacity: 0 } — and a WAAPI animation
composites above author CSS. Revealing before the definition arrives would
therefore override the guard, fade in unstyled light DOM, and then lose it
again the instant glint released the animation and the guard reasserted:
visible, invisible, visible. Waiting for whenDefined is what keeps a reveal
from writing a cheque the stylesheet has to bounce.
If the definition never arrives, the timeout ceiling still fires and still
releases the element with nothing inline left on it — so the page's own rules
are the only thing deciding, exactly as if glint had never loaded.
The wait is scoped to the element carrying data-reveal. A plain wrapper has a
resting opacity of 1 and animates immediately; its un-upgraded children come in
under their own guard. That is deliberate — deferring on any un-upgraded
descendant would stall a page's reveals on one slow definition.
Development
pnpm test # chromium
pnpm test:motion # under an emulated prefers-reduced-motion: reduce
pnpm test:all # both, across chromium, webkit and firefox
pnpm preview # demo pageCross-engine coverage matters here: the three engines sit in three different support tiers for scroll-driven CSS, which is why the layering exists.
test:motion runs in a browser context that genuinely reports reduced motion,
rather than stubbing matchMedia. A stub proves the branch behaves; it cannot
prove glint asks the question, or that the stylesheet guards its own hidden
states. Those tests fail without the emulation, which is the point of them.
License
MIT
