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

vinyl-kit

v0.1.0

Published

A tintable vinyl record built from one grayscale mask and a CSS blend mode — disc, sleeve with slide-out choreography, stacks, and cover-accent extraction. React, zero dependencies.

Readme

vinyl-kit

A vinyl record for the web, built from one grayscale mask and a CSS blend mode — so a single 31 KB asset paints a record in any colour you like.

Records, sleeves that slide their record out when you hover them, fanned stacks, and a hook that reads a cover's accent colour so artwork can pick its own palette. React, zero runtime dependencies.

npm install vinyl-kit
import { Vinyl, VinylSleeve } from "vinyl-kit";
import "vinyl-kit/vinyl.css";

<Vinyl cover="/covers/bones.jpg" tint="#7B5E99" size={220} spinning />;

Why it's built this way

Most CSS vinyl records are a stack of radial-gradient rings. They look like rings. This one uses a photographic groove mask — real grooves, real light — as a luminance-only layer over a flat colour fill:

  1. a solid fill in the record's tint,
  2. the cover art, centred in a small circle (the label),
  3. the groove mask, blended with mix-blend-mode: luminosity.

The mask contributes lightness and nothing else, so the grooves take their hue from the fill underneath, and the mask's transparent centre lets the label show through. One asset, every colour, no per-album artwork.

Features

  • Vinyl — a record, optionally spinning, optionally with cover art on its label.
  • VinylSleeve — an album sleeve with the record tucked behind it, which rolls out and tilts the cover when active. Driven by one inheritable custom property, so hover needs no JavaScript.
  • VinylStack — a crate of records that fans wider on hover.
  • useCoverAccent — samples a cover's accent colour in the browser, for tinting a record (or a whole page) from artwork.
  • Server-component safeVinyl, VinylSleeve and VinylStack use no hooks and no event handlers, so they render on the server untouched.
  • Bring your own image component — plain <img> by default, or hand it next/image, a CDN loader, anything.
  • Respects prefers-reduced-motion — transitions and spin both stand down.

Components

Vinyl

<Vinyl cover={album.cover} tint="#A15C08" size={200} spinning={isPlaying} />

| Prop | Type | Default | | | --- | --- | --- | --- | | cover | string \| null | — | Art for the centre label; omit for a plain record | | tint | string | #808080 | The record's colour | | size | number | — | Diameter in px; omit to fill the parent's width | | fill | boolean | false | Fill a positioned parent instead of self-sizing | | spinning | boolean | — | true spins, false freezes mid-revolution, omitted attaches no animation | | maskUrl | string | bundled | Your own groove mask | | alt | string | "" | Label alt text — the disc is decorative by default | | renderImage | RenderImage | plain <img> | See Bring your own image component |

spinning={false} pauses rather than resets: the record stops where it stopped, like lifting the needle.

VinylSleeve

<VinylSleeve cover={album.cover} alt={album.name} size={160} active={isOpen} reserve />

Adds to Vinyl's props:

| Prop | Type | Default | | | --- | --- | --- | --- | | size | number | required | The square sleeve's size in px; all geometry derives from it | | active | boolean | false | Slide the record out, tilt the cover | | reserve | boolean | false | Reserve layout width for the extracted record | | textureUrl | string \| null | bundled | Sleeve paper texture; null removes the layer |

Hover without JavaScript. The slide runs on --vinyl-extract (0 → 1), and custom properties inherit — so any ancestor can drive it from CSS alone:

.album-card:hover,
.album-card:focus-visible {
  --vinyl-extract: 1;
}

That's also why the extraction can be shared: the same hover can slide sibling text out of the record's way with transform: translateX(calc(var(--vinyl-extract, 0) * 31px)).

Use reserve wherever content sits to the right of the sleeve; without it the record overflows its box when it comes out (which is often exactly what you want in a grid).

VinylStack

<VinylStack size={180} items={albums.map((a) => ({ cover: a.cover, tint: a.accent }))} />

Item 0 sits on top and anchors the stack; the rest fan out to the right, each one rotated a little further. Hovering spreads the fan — a transform-only move, so nothing around it shifts.

Theming

Everything tunable is a custom property, so you restyle from your own CSS without touching props. Set them on the component, an ancestor, or :root.

| Property | Default | | | --- | --- | --- | | --vinyl-tint | #808080 | Groove colour | | --vinyl-size | — | The square cover size; all sleeve geometry derives from it | | --vinyl-extract | 0 | 0 resting → 1 extracted (inheritable) | | --vinyl-diameter | 0.94 | Record diameter, as a fraction of --vinyl-size | | --vinyl-peek | 0.16 | Resting overhang past the cover's edge | | --vinyl-slide | 0.46 | Extra slide when extracted | | --vinyl-roll | 2.5deg | Record rotation when extracted | | --vinyl-tilt | -2deg | Cover tilt when extracted | | --vinyl-label | 38% | Label diameter, as a fraction of the record | | --vinyl-ease | cubic-bezier(0.22, 1, 0.36, 1) | Slide/tilt easing | | --vinyl-duration | 0.55s | Slide/tilt duration | | --vinyl-spin-duration | 5s | One revolution | | --vinyl-backdrop | tint gradient | Behind the cover art while it loads | | --vinyl-disc-shadow | 0 6px 16px rgba(0,0,0,.4) | Record shadow | | --vinyl-sleeve-shadow | 0 0 10px rgba(0,0,0,.5) | Sleeve shadow | | --vinyl-stack-shift | 0.35 | Resting offset per record in a stack | | --vinyl-stack-shift-hover | 0.5 | Fanned offset per record | | --vinyl-stack-roll | -2deg | Rotation added per record |

States are data attributes, so you can style against them: [data-active="true"] on the sleeve, [data-spinning="true" \| "false"] on the record.

Colour from cover art

const { accent, status } = useCoverAccent(album.cover, { fallback: "#7B5E99" });

<Vinyl cover={album.cover} tint={accent} size={200} />;

A plain average of a cover is almost always mud — dark backgrounds and washed highlights win on pixel count. So each pixel is weighted toward what the eye actually latches onto, saturated mid-lightness colour:

weight = saturation^1.5 × (1 − min(1, |lightness − 0.5| × 1.8))

Covers with no real colour (greyscale, near-black) fall back to a plain average rather than returning nothing.

Reading pixels needs a CORS-clean image. The hook sets crossOrigin="anonymous", so the host must serve covers with a permissive access-control-allow-origin. If it doesn't, the canvas is tainted, status becomes "error", and accent stays your fallback — nothing breaks, the colour just doesn't refine. It's server-render safe: the value is derived during render, canvas work happens only in an effect, and each URL is sampled once per page load.

Snapping onto a designed palette usually beats using a raw sampled colour:

import { nearestColor } from "vinyl-kit";

const themes = ["#A15C08", "#7B5E99", "#59658A", "#8E4242"];
const theme = themes[nearestColor(themes, accent)];

nearestColor uses the redmean approximation — cheap, and far closer to how different two colours look than naive RGB distance. extractAccent, colorDistance and the hex/RGB helpers are exported too, and they're all DOM-free, so the same matching runs server-side (e.g. when generating share images from a decoded cover).

Bring your own image component

The package renders a plain <img> unless you say otherwise. renderImage receives { src, alt, sizePx } and must fill its positioned parent — which the stylesheet already arranges, so most adapters are one line:

import Image from "next/image";
import type { RenderImage } from "vinyl-kit";

const renderImage: RenderImage = ({ src, alt, sizePx }) => (
  <Image src={src} alt={alt} fill sizes={sizePx ? `${sizePx}px` : undefined} />
);

<VinylSleeve cover={cover} alt={name} size={160} renderImage={renderImage} />;

Next.js notes

Import the stylesheet once, in your root layout:

// app/layout.tsx
import "vinyl-kit/vinyl.css";

The components are server-safe, so they drop straight into server components — only useCoverAccent is a client module, and it carries its own "use client". The package ships compiled ESM with type declarations, so no transpilePackages entry (or equivalent) is needed in any bundler.

Bundle size

The groove mask and sleeve texture ship inlined as data URIs, so the package works the moment you import it — at a cost of roughly 133 KB in your JS bundle. To serve them as files instead, copy them out of the package's assets/ folder and point at them:

<VinylSleeve maskUrl="/vinyl-mask.webp" textureUrl="/cover-texture.png" … />

Your bundler then drops the inlined constants.

A note on blend modes

Two details are load-bearing, and both are in vinyl.css with comments:

  • isolation: isolate on the record contains the luminosity blend, so the mask composites against the record's own tint and label — never the page behind it.
  • transform: translateZ(0) pins the record to its own compositing layer. Without it, an ancestor's one-shot transform transition (a sleeve sliding out) briefly promotes then demotes the record, and Android Chrome mis-composites the nested blend mode during that churn — the grooves lose their colour for a frame or two, intermittently. The spin keyframes re-declare the same translateZ, since an animated transform would otherwise replace it.

Roadmap

  • 45 rpm / 7-inch variant with the wide centre hole
  • Gatefold sleeve
  • A framework-free web-component wrapper

License

MIT © Alexis Bohns — code and the bundled mask/texture artwork alike, so the package is usable as a whole.