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

@ropav/styles

v0.16.0

Published

Ropav core styles - framework-agnostic CSS

Readme

@ropav/styles

The style layer behind ropav: a rule set for every component, themes and utilities, plus tv() variants that do nothing but map props to class names. Framework-agnostic — not a line of Vue or React in it, no dependency, and no peer. Plain CSS either way you take it.

Theming guide · Tokens · Class names · State colors

Provenance

A derivative work of @heroui/[email protected], vendored into this workspace so the style layer can be fixed in place. See ropav for the rest.

Installation

Inside this repo it is a workspace dependency:

{"dependencies": {"@ropav/styles": "workspace:*"}}

Outside the repo you do not install it yourself — ropav depends on it and npm pulls it in.

Usage

Basic setup

@ropav/styles is the entry, a list of @import statements for anything that follows them; @ropav/styles/bundled.css is the same stylesheet already resolved, for a page with nothing to follow them. Either one, once, from your app's main CSS file:

/* your bundler resolves the imports */
@import "@ropav/styles";

/* already resolved */
@import "@ropav/styles/bundled.css";

The compiled file is also what the CDN fields point at, so a page can take it with no bundler:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ropav/styles" />

Reach for bundled.css if nothing in your pipeline follows a CSS @import. The entry above is a set of them, so unresolved it succeeds and produces nothing — the app renders unstyled and nothing errors. That shipped once.

Either way you get, in layer order (theme, base, components, utilities):

  • the scoped reset, and the keyframes the components animate through
  • base styles and the scrollbar system
  • the component layer — 89 files, one per component
  • the default theme: tokens for light and dark, and every token a rule names directly
  • the classes offered by name rather than through a component

The one thing it does not carry is the page's own font-family and line-height. The reset is scoped to the rp- prefix, and a rule that stops at the component cannot set those — they live on html and belong to the app. Any ordinary reset supplies them; without one the components inherit the browser's default, which is a serif.

Importing only what you need

The entry is two halves, each importable on its own: core.css, everything the components stand on, and components.css, every component file in the components layer. Take the core first and once, then the component files you draw:

@import "@ropav/styles/core.css";

@import "@ropav/styles/components/button.css" layer(components);
@import "@ropav/styles/components/chip.css" layer(components);

The core is the layer order, the reset, the base styles, the registrations, the motion switch, the keyframes, the default theme, the tokens and the utility classes; leaving any of it out fails quietly rather than loudly. A component file holds that component's rules only — a modal's close button is close-button.css — and ropav/styles/<name> is the same thing with what each component draws already listed.

components.css carries no theme, so it is the half that can arrive after the first paint: a theme loaded then would land after the app's own @layer theme tokens and win over them.

./bundled.css and the pattern subpaths — ./base, ./base/*.css, ./themes/*, ./themes/*.css, ./utilities — exist only in the published tarball; clean-package.config.json writes them into exports at prepack time. Inside the workspace, import the files from packages/styles/ by relative path instead. ./core.css, ./components.css and ./components/*.css resolve in both.

Variants

import {buttonVariants, type ButtonVariants} from "@ropav/styles";

buttonVariants({variant: "primary", size: "sm"}); // "rp-button rp-button--primary rp-button--sm"

Every component also has its own subpath so bundlers can drop the rest: @ropav/styles/components/button.

Package structure

packages/styles/
├── index.css              # Entry point — declares layer order, then the two halves below
├── core.css               # Everything the component files stand on: base, theme, tokens, utilities
├── components.css         # Every component file, in `@layer components`, and nothing else
├── motion.css             # `--rp-motion`, the switch every animated declaration reads
├── slots.css              # `@property` registrations the component rules compose through
├── animations.css         # Every keyframe, names being document-global
├── base/
│   ├── reset.css          # The reset, scoped to the `rp-` prefix
│   ├── base.css           # Layout tokens, typography
│   └── scrollbar.css      # Scrollbar system
├── components/            # 88 CSS files, one per component
├── themes/
│   ├── default.css        # Default theme — hand-written, light and dark token sets
│   ├── sky.css … rabbit.css  # Ten more themes — generated, do not edit
│   ├── all.css            # Every bundled theme, for docs and playgrounds
│   └── shared/
│       └── tokens.css     # Every token a rule spells directly — type scale, weights, curves
├── utilities/index.css    # The classes offered by name rather than through a component
├── scripts/themes/        # Build-time theme generator — not published
└── src/                   # TypeScript: tv() variants and the bundled theme list

Class naming

Prefixed BEM, so a class can be read without looking it up and belongs to nobody else:

  • Prefix — rp-, on every class the component layer defines
  • Block — the component itself: .rp-button, .rp-card, .rp-alert
  • Modifier — a variation, double dash: .rp-button--primary, .rp-button--lg, .rp-button--icon-only
  • Element — a part of the component, double underscore: .rp-card__header, .rp-alert__icon
<button class="rp-button">Click me</button>
<button class="rp-button rp-button--primary">Save</button>
<button class="rp-button rp-button--primary rp-button--sm">Small primary</button>

The prefix is not decoration. Component rules live in the components layer and a host app's CSS is usually unlayered, so the host wins every property it declares — and what leaks onto it is every property it never mentioned. A host element wearing a bare menu or card would inherit half a component and no error anywhere. rp- is what stops the library from claiming names it does not own; keyframes and view-transition-class names carry it for the same reason, being document-global too. tests/styles/class-prefix.test.ts in ropav fails on any that does not.

Two conventions the whole layer relies on:

Default size lives in the base class. .rp-button already renders at the --md size, so .rp-button--md is an empty rule with a comment saying why. A component with no size modifier never looks broken.

State keys on data-*, with a pseudo-class fallback. Interactive rules are written as &:hover, &[data-hovered="true"], &:active, &[data-pressed="true"], &:focus-visible, &[data-focus-visible="true"] — so the same CSS works whether state comes from the browser or from a component that publishes it as an attribute.

components/button.css is the densest example of both — read it before writing a new component file.

Every ropav component takes a class prop, appended to the classes its tv() recipe carries. A utility passed that way wins on cascade order alone — component rules live in the components layer, utilities in the later utilities one — so nothing has to be stripped for it to take effect. That is the first thing to reach for. It cannot reach a part drawn in ::before/::after, and it flattens every state of whatever property it sets — see State colors for the way round both.

Theming

Two independent axes. data-theme picks the palette, a light/dark class picks the appearance:

<html data-theme="netflix" class="dark"></html>

Either can be left out. With no data-theme you get the default theme; with no class you get light. The default theme also answers to [data-theme="light"] and [data-theme="dark"], which is what it did before the other themes existed.

Bundled themes

The default one, plus a theme per brand ported from HeroUI's gallery — hero among them, being HeroUI's own palette. The default is already in @ropav/styles; the others are opt-in, one file each:

@import "@ropav/styles";
@import "@ropav/styles/themes/netflix";

| data-theme | | data-theme | | data-theme | | --- | --- | --- | --- | --- | | default | | mint | | coinbase | | hero | | netflix | | airbnb | | sky | | uber | | discord | | lavender | | spotify | | rabbit |

Each carries both a light and a dark palette, so a theme is a brand rather than an appearance. There is no netflix-dark — it is data-theme="netflix" plus class="dark".

themes/all.css pulls in all of them at once. That is for docs and playgrounds; an app should import only the themes it actually offers, since each is around 10 kB before compression.

Every theme but default and hero is generated — edit scripts/themes/presets.ts and run pnpm generate:themes, never the CSS. A preset is four numbers (accent lightness, chroma and hue, plus the chroma of the neutral ramp), a radius pair, and any exact brand colours that should beat the calculated ones.

Those two stay hand-written, each file's header saying what the generator cannot reproduce about it. Their presets carry the label and the presentation order; the CSS is the source of truth for their tokens.

Retheming

Every token is a CSS custom property, so overriding them in your own stylesheet is enough — no rebuild of this package, and no need to go through a theme file at all:

:root {
  --rp-accent: oklch(0.62 0.19 253.83);
  --rp-radius: 0.5rem;
}

Author CSS outside a cascade layer outranks everything here, so these win wherever you put them.

To add a theme rather than change the default one, write the same token block under your own attribute. It has to redeclare the derived tokens too, not just the authored ones: a custom property substitutes var() where it is declared, so an --rp-accent-hover inherited from :root would still be mixed from the root's --rp-accent. Copy a generated theme and edit it, or generate one.

@layer theme {
  [data-theme="ocean"] {
    color-scheme: light;
    --rp-accent: oklch(0.62 0.14 220);
    --rp-accent-hover: color-mix(in oklab, var(--rp-accent) 90%, var(--rp-accent-foreground) 10%);
    /* … */
  }
}

A theme only needs to carry colours. Everything keyed on neither the palette nor the appearance — --rp-spacing, --rp-cursor-*, the primitives, the shadows, --rp-backdrop — stays on :root and .dark in themes/default.css, both of which keep matching an element that carries a data-theme.

Tokens

Base colors (--rp-background, --rp-surface, --rp-overlay, --rp-muted), interactive and status colors (--rp-accent, --rp-success, --rp-warning, --rp-danger, each with a -foreground and a derived -hover and -soft), form fields (--rp-field-background, --rp-field-border, --rp-field-radius, …), layout knobs (--rp-spacing, --rp-border-width, --rp-radius, --rp-component-radius, --rp-focus-ring-offset, --rp-cursor-interactive), the scrollbar set, and shadows.

--rp-field-radius rounds the fields and --rp-component-radius nearly everything else — buttons, toggle buttons and their groups' ends, tabs, alerts, surfaces, chips and tags. The default theme sets --rp-component-radius: var(--rp-field-radius), so a button under a text field meets it corner for corner; the bundled palettes leave it at calc(var(--rp-radius) * 3). A theme pairs the two by setting that one token on the element that declares the theme.

themes/default.css is the source of truth — read it rather than a list in a README, which goes stale the moment a token moves. themes/shared/tokens.css holds the rest of what a rule can name directly: the type scale, the weights and the easing curves.

There is no --rp-radius-* scale to read. A step is a multiple written where it is used — calc(var(--rp-radius) * 3) — so a data-theme setting its own --rp-radius moves every corner at once, which a scale declared on :root could not do.

State colors

A component's own properties are the third tier, declared on its block and private to its file — --button-bg, --switch-control-bg-checked-hover, --sidebar-rail-line. The convention:

A color a state rule paints goes through a custom property — where more than one state paints that property.

That is what makes one state retunable on its own. A color written straight into the rule is not: the resting and the lit value are the same property on the same element, so the one declaration a caller writes to change either beats every state rule at once — and a utility beats them all whatever their specificity, utilities being a later layer than components. The only way left is to restate the whole state set behind :not(), which drifts silently the day a state is added.

So a caller sets the property instead, and the states that read the others are left standing:

<!-- No line at rest; it still flares on hover and while dragging. -->
<Sidebar style="--sidebar-rail-line: transparent">

A custom property set inline lands on the element the component rules read it from and beats every one of them, without naming a layer or a selector — and without a build. For every instance rather than one, write the property in a rule of your own; author CSS is unlayered, so it outranks the components layer wherever you put it.

The qualifier is what keeps this from becoming surface for its own sake. Where exactly one state paints a property, a caller can name that state themselves — a rule on :hover or :focus-visible, or the matching utility if they run one — and there is no other state for it to flatten, so no property is minted. It is the second state painting the same thing that makes the call site unable to tell them apart.

Defaults chain, so retuning one carries the states below it unless they are set too — --sidebar-rail-line-dragging follows --sidebar-rail-line-hover, the way --button-bg-pressed follows --button-bg-hover. Read components/button.css for the shape and components/sidebar.css for a part painted on a pseudo-element, which a class cannot reach at all.

Two limits. The properties are per component and resolve to the tokens above, so a palette change belongs in a theme rather than here. And Forced Colors Mode is not covered: those blocks paint the system keywords, which are the only colors exempt from the override and not a component's to retune — setting --sidebar-rail-line leaves the line drawn under High Contrast, on purpose.

state-colors.test.ts in the ropav package reads every component file and fails on a state color that does not go through a property. Its ledger of exceptions is currently empty, so an entry appearing there is debt to pay down rather than a licence to add more.

Translucent surfaces

A surface--transparent paints nothing of its own, and so does a surface a caller has thinned to let something through — a blurred header, a panel over an image, a window whose material the OS draws. What sits on one of those has no way to know what is behind it, and that splits the palette in two.

Safe on anything: the tokens that are an alpha. --rp-state-layer is what a state paints, and --rp-separator and --rp-border are the lines — each an alpha of --rp-surface-foreground rather than a neutral of its own. An alpha darkens or lightens whatever it lands on, so it holds its contrast against a fill it was never tuned for: another surface, the far half of the theme, or a photograph. The -soft family and --rp-scrollbar-thumb are alphas too, which is why a selection is --rp-accent-soft wherever the library marks one.

Not safe: the fills. --rp-default, --rp-accent, the status colors, --rp-surface and its secondary and tertiary steps are opaque, and they are meant to be — a badge you can read the page through is a broken badge. They assume a background because they are one. Use them to fill a box, never to mark a state on a box that is already transparent.

--rp-surface-hover sits on the fill side of that line despite the name: it is a surface that has been tinted and stays opaque, for a component opaque at rest whose hover must stay that way. --rp-state-layer is the one for a component transparent at rest. Reaching for the wrong one fails quietly — a translucent hover on an opaque tag shows the page through it, and a flat neutral on a transparent row vanishes the moment the fill behind it matches.

The line between the two is what a color is for, not what it looks like. --rp-default is a fine grey until it is asked to be a state layer: then it is a fixed lightness laid on an unknown one, and the day those two match it is gone with nothing to fail but the eye.

Contrast on a translucent surface is the app's to guarantee. contrast.browser.test.ts holds the floor for text on the fills, where both sides are known. Once the background is whatever happens to be behind the window, ropav cannot see it and does not claim to — an app that thins a surface owns the legibility of what it puts there.

Reduced motion

data-reduce-motion="true" on any ancestor forces animations off, "false" forces them on regardless of the OS setting, and with neither the prefers-reduced-motion media query decides. The nearest ancestor that answered is the one that counts.

The attribute sets --rp-motion — see motion.css — and every animated declaration leads with var(--rp-motion), which substitutes nothing when motion is allowed and invalidates the whole declaration when it is not. Tailwind's own motion-reduce / motion-safe variants read the media query directly and know nothing about the attribute.

Forced colors

Forced Colors Mode - Windows High Contrast - replaces author colours with the user's own palette and strips box-shadow outright. That second part is what makes it more than a colour question here: every ring in this library is a box-shadow, composed in a slot so that a ring and a drop shadow can share the property, and it sits on top of outline-none. Left alone, a focused control would have no indicator at all.

Nothing to opt into. The three focus utilities - focus-ring, focus-field-ring, invalid-field-ring - draw an outline back in Highlight under forced-colors: active, so any component that goes through status-focused, status-focused-field or status-invalid-field is covered without a line of its own. status-disabled picks up GrayText the same way.

The other half is state carried only by background-color, which the override flattens into its surroundings. Where selection is just a background - a tag, a calendar day, a table row - state it again in system colours, after the fill:

.thing[data-selected="true"] {
  background-color: var(--rp-accent);
  color: var(--rp-accent-foreground);

  @media (forced-colors: active) {
    forced-color-adjust: none;
    background-color: Highlight;
    border-color: Highlight;
    color: HighlightText;
  }
}

The block has to come after the fill it overrides, which used to be a trap worth a paragraph: as @apply forced-selected it had to stand as a statement of its own, because Tailwind sorted a list and hoisted the nested media query above the plain declarations. Written out there is nothing to sort. .forced-selected is still there as a class if a caller wants it. Where selection also moves a thumb or shows a glyph, the component writes its own forced-colors block, because those parts need colours of their own - see switch.css, radio.css, tabs.css, range-calendar.css, slider.css and skeleton.css.

Use the system colour keywords, not tokens: Highlight / HighlightText for a selected control, CanvasText on Canvas for ordinary content, ButtonBorder for a control's edge, GrayText for disabled. They are the only colours exempt from the override.

Watch for transparent used as a spacer. Forced colors preserves a transparent background, but a transparent border-color is turned opaque - so a border held open purely to reserve layout space (slider.css does this at both ends of the track) stops being invisible and renders as a solid block. Restate those as Canvas inside the media query.

One trap, and getComputedStyle cannot see it. Chromium paints a Canvas-coloured backplate behind the text of any element that has text, so that text over an image stays legible. It lands on top of that element's own background, so a Highlight fill carrying HighlightText renders as a solid plate with the label invisible inside it - the colours are all correct and the component is unreadable. forced-color-adjust: none is what suppresses the backplate. forced-selected already carries it; anything hand-rolling the same pairing needs it too, including the case where the fill sits on a different element than the text (tabs.css, range-calendar.css). Reach for it only alongside explicit system colours - on its own it just hands the author's palette back, which is the opposite of the point.

Structure has to survive the mode as well as state. A component that is only a tinted fill and a shadow - a card, an alert, a text field, a chip - renders as loose text once both are taken, which reads as no component at all. So every container carries an inset CanvasText outline under the mode and every control a ButtonBorder one, on the variants that actually paint; a transparent surface is left alone, since framing it would invent a box that was never there. A separator is the exception that proves it: the whole thing is its background, so it takes a colour rather than an edge.

Two things guard it. forced-colors.browser.test.ts in the ropav package asserts the stylesheet directly, and the Storybook package runs every story through an audit that renders it twice - the mode off, then on over CDP - and fails on anything that painted something and stops painting it. The second one exists because the first cannot see this class of bug on its own: the colour override runs after the cascade and the backplate is painted later still, so a component reports every declared colour correctly while rendering as a blank block. Three bugs shipped that way before the audit existed.

To look at it by hand, use Chromium's rendering panel - DevTools, Cmd+Shift+P, "Show Rendering", then Emulate CSS media feature forced-colors - and note that macOS has no Forced Colors Mode at all: "Increase contrast" maps to prefers-contrast: more, so switching it on tests nothing. There is deliberately no toolbar control in Storybook. data-reduce-motion is an attribute a decorator can set, but this is a browser-level media feature an iframe cannot turn on for itself, and faking it with a stylesheet of system colours would miss the box-shadow removal - the part that actually breaks.

Build

pnpm --filter @ropav/styles build

Rolldown emits dist/ as ES modules with preserveModules and one entry per component, tsc emits the .d.ts files, the CSS is copied across untouched, and scripts/bundle-css.mjs produces the minified dist/ropav.min.css.

That last step is Lightning CSS: it follows the @import graph, lowers nesting for the browser floor and minifies. The floor is stated in that file rather than queried from a browserslist, because a floor that moves on a lockfile bump is not a floor. packages/ropav builds its own ropav.min.css through the same function, and bundled.browser.test.ts in that package renders the result against the source to check that the compiler changed nothing anyone can see.

pnpm --filter @ropav/styles measure-size prints a size report and writes bundle-size.json.

Only the published tarball needs the build. In the workspace, exports point straight at the sources (src/index.ts and index.css), so editing a .css file shows up in Storybook and in the browser test suite with no build step.

License

Apache-2.0 — see LICENSE at the repo root, which keeps the original copyright.