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

stablekit.js

v0.2.0

Published

React toolkit for layout stability — zero-shift components for loading states, content swaps, and spatial containers.

Readme

StableKit

React components that make layout shift structurally impossible.

The Problem

React couples two things that should be independent: what a component looks like (paint) and how much space it takes up (geometry). When state changes, both change at once, and the browser reflows the page.

// When isLoading flips, the spinner is destroyed and replaced by a table.
// The browser reflows the entire page.
{isLoading ? <Spinner /> : <DataTable rows={rows} />}

Every ternary, every conditional render, every {data && <Component />} is a geometry mutation disguised as a paint change. This is not a bug — it's the default rendering model.

The Fix

One rule:

A container's dimensions must be a function of its maximum possible future state, not its current instantaneous state.

Geometry is pre-allocated before data arrives, before the user clicks, before the state changes. Paint changes freely. Geometry never moves.

// One tree describes both loading and loaded states.
// The geometry is identical in both. Only the paint changes.
<LoadingBoundary loading={isLoading} exitDuration={150}>
  <MediaSkeleton aspectRatio={1} className="w-16 rounded-full">
    <img src={user.avatar} alt={user.name} />
  </MediaSkeleton>
  <StableText as="h2" className="text-xl font-bold">{user.name}</StableText>
  <StableText as="p" className="text-sm text-muted">{user.email}</StableText>
</LoadingBoundary>

Install

npm install stablekit

Three Kinds of Stability

Temporal Pre-allocation

If a component depends on async data, its bounding box is declared synchronously before the data arrives. MediaSkeleton forces an aspectRatio. CollectionSkeleton forces a stubCount. StableText reserves space at the exact line-height of the text it will display.

Spatial Pre-allocation

If a UI region has multiple states, all states render simultaneously in a CSS grid overlap. The container sizes to the largest. LayoutMap renders a dictionary of views, toggles visibility with [inert] + data-state, and never changes dimensions. StateSwap does the same for boolean content inside buttons and labels.

<LayoutMap value={activeTab} map={{
  profile: <Profile />,
  invoices: <Invoices />,
  settings: <Settings />,
}} />

<button onClick={toggle}>
  <StateSwap state={expanded} true="Close" false="View Details" />
</button>

Monotonic Geometry

Once a container expands, it cannot shrink unless explicitly reset. SizeRatchet tracks the maximum size ever observed and applies min-width/min-height that only grows. resetKey resets the floor when the context changes.

Components

| Component | What it does | |---|---| | LoadingBoundary | Loading orchestrator — composes shimmer + ratchet + exit transition | | StableText | Typography + skeleton in one tag | | MediaSkeleton | Aspect-ratio placeholder that constrains its child | | CollectionSkeleton | Loading-aware list with forced stub count | | LayoutMap | Type-safe dictionary of views with stable dimensions | | LayoutGroup + LayoutView | Multi-state spatial container (use LayoutMap when possible) | | StateSwap | Boolean content swap — both options rendered, zero shift | | StableCounter | Numeric/text width pre-allocation via ghost reserve | | StableField | Form error height pre-allocation via ghost reserve | | SizeRatchet | Container that never shrinks (ResizeObserver ratchet) | | FadeTransition | Enter/exit animation wrapper, geometry untouched | | createPrimitive | Factory for UI primitives with architectural enforcement |

Keeping Visual Decisions in CSS

There's a problem adjacent to layout stability: visual decisions leaking into JavaScript.

A component's appearance can change for two reasons. Identity — a brand button is always indigo because that's the brand. Data — a badge is green when active and red when churned. Identity is fixed and belongs in a className. Data-dependent appearance changes at runtime based on values the component receives.

When a component picks its own visuals based on data — className={status === "paid" ? "text-green-500" : "text-red-500"} — the visual decision lives in JavaScript. Changing a color means editing a .tsx file. A designer can't update the palette without a developer. A developer can't refactor the component without understanding the color system.

The fix is a hard boundary: components declare what state they're in, and CSS decides what that looks like. A <Badge> says data-variant="active". CSS says .sk-badge[data-variant="active"] { color: green }. The component never knows its own color, font weight, border, opacity, or any other visual property that depends on data. It only knows its state.

createPrimitive

createPrimitive makes this boundary automatic. It builds UI primitives where className and style are blocked at the type level, and variant props are mapped to data-* attributes:

import { createPrimitive } from "stablekit";

const Badge = createPrimitive("span", "sk-badge", {
  variant: ["active", "trial", "churned"],
});

Consumers get type-checked variants and a locked-down surface:

<Badge variant="active">Paid</Badge>         // renders data-variant="active"
<Badge variant="bogus">Paid</Badge>          // TypeScript error
<Badge className="text-red-500">Paid</Badge> // TypeScript error

CSS owns the visuals:

.sk-badge[data-variant="active"] { color: var(--color-success); }
.sk-badge[data-variant="trial"]  { color: var(--color-warning); }

Changing a color means editing CSS. Never a component file.

Architecture Linters

StableKit ships two linter factories that enforce the Structure → Presentation boundary on both sides:

ESLint (stablekit/eslint) — catches visual decisions leaking into JS:

// eslint.config.js
import { createArchitectureLint } from "stablekit/eslint";

export default [
  createArchitectureLint({
    stateTokens: ["success", "warning", "destructive"],
    variantProps: ["variant", "intent"],
  }),
];

stateTokens declares your project's functional color vocabulary — the token names that represent data-dependent state. The linter flags bg-success, text-warning, etc. in className strings (these should use data-* attributes and CSS).

variantProps declares the prop names from your createPrimitive calls. The linter flags ternaries on these props — intent={x ? "primary" : "outline"} is a visual decision in JS. If a variant changes based on data, the component should use a data-attribute and CSS should handle the visual difference.

It also catches universally: bare hex color literals ("#f0c040"), color functions (rgba(), hsl(), oklch()), color properties in style props (style={{ color: x }}), className ternaries (className={x ? "a" : "b"}), and conditional style ternaries.

Stylelint (stablekit/stylelint) — catches CSS targeting child elements by tag name:

// stylelint.config.js
import { createStyleLint } from "stablekit/stylelint";

export default createStyleLint({
  functionalTokens: ["--color-status-", "--color-danger"],
});

This bans element selectors like & svg { color: green } — set color on the container and let currentColor inherit. Bans !important. And with functionalTokens, bans functional color tokens inside @utility blocks — @utility text-status-success { color: var(--color-status-active) } is an error because it launders a functional color into a reusable className, crossing back from Presentation into Structure.

How It Works

Spatial stability uses CSS grid overlap (grid-area: 1/1). All views render in the DOM simultaneously. The container auto-sizes to the largest child. Inactive views are hidden with [inert] + data-state="inactive" (CSS-driven opacity/visibility). Consumers can add CSS transitions to .sk-layout-view[data-state] for custom animations.

Loading skeletons use 1lh CSS units to match line-height exactly. Shimmer width comes from inert ghost content — the skeleton is exactly as wide as the text it replaces.

MediaSkeleton constrains its child via cloneElement inline styles (position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover). No CSS !important.

SizeRatchet uses a one-way ResizeObserver. It tracks the maximum border-box size ever observed and applies min-width/min-height that only grows.

CSS Custom Properties

--sk-shimmer-color: #e5e7eb;
--sk-shimmer-highlight: #f3f4f6;
--sk-shimmer-radius: 0.125rem;
--sk-shimmer-duration: 1.5s;
--sk-skeleton-gap: 0.75rem;
--sk-skeleton-bone-gap: 0.125rem;
--sk-skeleton-bone-padding: 0.375rem 0.5rem;
--sk-fade-duration: 200ms;
--sk-fade-offset-y: -12px;
--sk-fade-offset-scale: 0.98;
--sk-loading-exit-duration: 150ms;
--sk-ease-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1.0);
--sk-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--sk-ease-accelerate: cubic-bezier(0.4, 0, 1, 1);

Style Injection

Styles are auto-injected via a <style data-stablekit> tag on first import. To opt out (e.g. if you import stablekit/styles.css manually):

<meta name="stablekit-disable-injection" />

For CSP nonce support:

<meta name="stablekit-nonce" content="your-nonce-here" />

License

MIT