npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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: reduce is 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> or init({ 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 pending

glint.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 pass observeMutations: true.
  • content-visibility: auto gives 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 page

Cross-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