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

@jts-studios/web-components

v0.9.0

Published

Performance-first, framework-free UI components by JTS Studios

Readme

@jts-studios/web-components

Performance-first, framework-free UI components. One dependency (@jts-studios/utils, left external so your bundler dedupes it), no shadow DOM, full TypeScript bindings.

Installation

npm install @jts-studios/web-components

Import only what the page renders

Each component is its own entry point with its own stylesheet, so a page pays for nothing it does not use:

import "@jts-studios/web-components/carousel.css"
import "@jts-studios/web-components/carousel"        // registers <jts-carousel>

import "@jts-studios/web-components/scrolldown.css"
import "@jts-studios/web-components/scrolldown"      // registers <jts-scrolldown>

import "@jts-studios/web-components/decipher"        // <jts-decipher>, no CSS
import "@jts-studios/web-components/connections"     // <jts-connections>, no CSS
import "@jts-studios/web-components/smooth-scroll"   // no element; starts on import

import "@jts-studios/web-components/notification.css"
import { notify } from "@jts-studios/web-components/notification"  // <jts-notification>

import { onScrollFrame } from "@jts-studios/web-components/core"  // no elements, no CSS

| Entry | JS (min) | CSS | Registers | |---|---|---|---| | /core | ~5 kB | — | nothing | | /decipher | ~4.5 kB | — | <jts-decipher> | | /connections | ~4 kB | — | <jts-connections> | | /smooth-scroll | ~4 kB | — | nothing; starts on import | | /scrolldown | ~3.4 kB | 1.8 kB | <jts-scrolldown> | | /notification | ~9 kB | 6.5 kB | <jts-notification>, <jts-notifications> | | /carousel | ~17 kB | 2.0 kB | <jts-carousel> | | . (everything) | ~34 kB | 10 kB | all five |

/core is side-effect free, so a bundler drops the parts of it you do not reference — importing only onScrollFrame pulls ~400 bytes, not the whole file.

Or take the lot

import "@jts-studios/web-components/style.css"
import "@jts-studios/web-components"   // registers every custom element

Elements register themselves on import: <jts-carousel>, <jts-scrolldown>, <jts-decipher>, <jts-notification>, <jts-notifications>, <jts-connections>. The classes behind them (Carousel, Decipher) and the shared primitives (DragController, subscribe, onScrollFrame) are named exports. A single-file UMD build ships too, for <script> tags and require().


Carousel

A seamless infinite marquee — logos cycle continuously and can be grabbed and flung like a scroll area.

How it works

The track holds one authored set of items plus enough cloned sets to cover the viewport. Motion is a single translate3d on the track, and the offset wraps within one set width, so the loop never touches the DOM while running and never grows the track over time.

That design drives the performance characteristics:

  • One rAF for the whole page. Every instance subscribes to a shared ticker, so ten carousels still cost one callback per frame.
  • Zero cost when idle. Instances unsubscribe entirely when scrolled offscreen (IntersectionObserver), when paused on hover, and when a fling has settled. A paused carousel burns no CPU.
  • No layout reads while animating. pointermove banks its delta and the frame applies it; widths are only re-read when ResizeObserver reports an actual change (late images, web fonts, container resize).
  • Frame-rate independent. Speed is in px/second and momentum decay is time-based, so motion matches across 60Hz and 144Hz. Frame deltas are clamped, so a stalled tab never jumps.

Usage

Declarative — the element adopts its own children, so the logos are in the served HTML and render before the script runs:

<jts-carousel label="Trusted by" speed="45" gap="70px">
    <img src="/logos/acme.svg" alt="Acme" />
    <img src="/logos/globex.svg" alt="Globex" />
    <img src="/logos/initech.svg" alt="Initech" />
</jts-carousel>

Imperative — pass items as data:

const carousel = new Carousel({
    container: document.querySelector("#logos"),
    items: [
        { src: "/logos/acme.svg", alt: "Acme", href: "https://acme.com" },
        { src: "/logos/globex.svg", alt: "Globex" },
    ],
    speed: 45,
})

Or adopt existing markup from any container:

new Carousel({ container: document.querySelector("#logos") })

Options

| Option | Type | Default | Description | |---|---|---|---| | container | HTMLElement | null | Mount target; passing it here mounts immediately | | items | array | null | Items to render; omit to adopt the container's children | | speed | number | 40 | Autoplay speed in pixels per second | | direction | 1 \| -1 | 1 | 1 scrolls content leftwards, -1 rightwards | | autoplay | boolean | true | Start scrolling on mount | | gap | number \| string | null | Space between items; a number means pixels | | draggable | boolean | true | Enable grab-to-scroll | | momentum | boolean | true | Carry momentum after a fling | | friction | number | 0.94 | Velocity retained per 60fps frame | | pauseOnHover | boolean | true | Pause while the pointer is over the carousel | | pauseOnFocus | boolean | true | Pause while focus is inside | | keyboard | boolean | true | Arrow-key movement when focus is inside | | keyStep | number | 120 | Pixels moved per arrow-key press | | fade | boolean | true | Fade the left and right edges | | respectReducedMotion | boolean | true | Suspend autoplay under prefers-reduced-motion | | label | string | "Carousel" | Accessible name for the region | | className | string | "" | Extra class names for the root | | renderItem | (item, i) => Element \| string | null | Custom item renderer | | minSets | number | 2 | Lower bound on repeated sets |

Attributes

<jts-carousel> mirrors the options in kebab-case: speed, direction ("left" / "right"), gap, label, friction, key-step, autoplay, fade, keyboard, momentum, pause-on-hover, pause-on-focus, and no-drag to disable dragging. speed and direction update live; the rest remount.

Methods

| Method | Description | |---|---| | mount(root) | Build and start inside root | | destroy() | Tear down and restore adopted markup | | play() / pause() | Control autoplay | | scrollBy(px) | Move by pixels; positive scrolls forward | | setSpeed(px) | Change autoplay speed | | setDirection(1 \| -1) | Change direction | | setItems(items) | Replace contents, keeping scroll position | | refresh() | Re-measure after an external layout change |

Styling

Everything is light DOM, so the classes are yours to target. Three custom properties cover what the layout itself depends on; everything else is ordinary CSS on the classes below.

.jts-carousel {
    --jts-carousel-gap: 70px;
    --jts-carousel-fade: 120px;
    --jts-carousel-item-height: 28px;
}

.jts-carousel__item {
    opacity: 0.6;
    filter: grayscale(1);
    transition: 0.3s;
}

.jts-carousel__item:hover {
    opacity: 1;
    filter: none;
}

Structure: .jts-carousel.jts-carousel__viewport.jts-carousel__track.jts-carousel__set.jts-carousel__item. The root also carries .is-dragging while grabbed.

Accessibility

The root is a labelled region. Cloned sets are aria-hidden and their focusable contents are given tabindex="-1", so screen readers and the tab order only ever see the authored items once — while the clones stay clickable, which matters because most of what is on screen at any moment is a clone. Autoplay pauses on hover and on focus, arrow keys move the track, and autoplay is suspended under prefers-reduced-motion: reduce while dragging stays available. A drag that ends over a link is not treated as a click.


Scrolldown

An animated scroll cue that scrolls the page on click and fades out once the visitor has started.

<jts-scrolldown label="Scroll to explore"></jts-scrolldown>
<jts-scrolldown label="See our work" target="#section-work"></jts-scrolldown>

| Attribute | Default | Description | |---|---|---| | label | "Scroll to explore" | Caption and accessible name | | target | — | CSS selector to scroll into view; takes precedence over distance | | distance | one viewport | Pixels to scroll when there is no target | | hide-after | 0 | Scroll position past which it hides |

It is a real button: role="button", keyboard-reachable, and Enter/Space activate it. The jump is instant rather than smooth under prefers-reduced-motion, and the pill stops bobbing.

Styling is entirely custom properties — colour defaults to currentColor, so one declaration themes it:

.hero jts-scrolldown {
    --jts-scrolldown-color: #b2becd;
    --jts-scrolldown-color-hover: #f0f8ff;
    --jts-scrolldown-width: 24px;
    --jts-scrolldown-height: 42px;
    --jts-scrolldown-label-offset: -30px;
}

Decipher

Text that scrambles away and resolves into the next word.

<jts-decipher words='["J0T0S0 Dev", "Giovanni Tosato"]'>J0T0S0 Dev</jts-decipher>

The element's existing text is the fallback — what shows before the script runs, and what stays if words is missing or malformed, so a bad attribute never blanks the page.

| Attribute | Default | Description | |---|---|---| | words | — | JSON array of strings to cycle | | attempts | 3 | Scrambled frames per character before it settles | | attempt-delay | 40 | Milliseconds between frames | | cooldown | 3000 | Pause on a finished word before cycling | | autoplay | true | Set false to advance only via the class API |

The animation runs only while the element is on screen, every delay is cancellable, and tearing it down leaves a whole word behind rather than a half-erased one. Under prefers-reduced-motion the first word is shown without scrambling.


Notifications

One visual language for everything a page tells a person, whether the server rendered it or a script raised it.

<jts-notification variant="error">Your current password is not right.</jts-notification>
import { notify } from "@jts-studios/web-components/notification"

notify.error("Could not reach the payment provider.")
notify.success("Saved.")

Light DOM, on purpose

Most notifications on a server-rendered page are already in the HTML when it arrives. They have to be readable and correctly styled before this component's JavaScript has been fetched, so the stylesheet targets the jts-notification tag rather than a class applied on upgrade. The element only adds the dismiss button and the live region; it is never what makes the message appear.

Which means a page with JavaScript turned off still shows every message it was sent, in the right colour.

The variant decides the live region

| Variant | role | Announced | |---|---|---| | error | alert | immediately | | warning | alert | immediately | | success | status | at the next pause | | info | status | at the next pause | | neutral (default) | status | at the next pause |

A failure interrupts, because the person is being told the thing they just tried did not happen. Everything else waits. Choosing this by hand at each call site is how a codebase ends up with a mix of both and a few with neither — set variant and the role follows. A role already in the markup is left alone.

<jts-notification>

| Attribute | Default | Description | |---|---|---| | variant | neutral | error, warning, success, info, neutral | | dismissible | absent | Present to add a close button | | duration | 0 | Milliseconds until it dismisses itself; 0 stays | | dismiss-label | "Dismiss" | Accessible name for the close button | | animation | inherited from the region | slide, bounce, fade, scale, none, or your own |

| Method | Description | |---|---| | show(message, variant?) | Set the text and show it, re-announcing even if the text is unchanged. Passing a variant moves it to that variant's live region | | clear() | Hide and empty it, ready to be used again | | dismiss(reason?) | Play the exit animation, hide, and fire the two events; returns false if a listener cancelled or it is already leaving | | enter() / leave() | Play one animation. Both return a promise that resolves when it has finished |

Two events, and the difference matters: jts-notification:dismiss fires when a dismissal starts and is cancelable; jts-notification:dismissed fires once the exit animation has finished and it is hidden. A region removes its children on the second one, so swapping in a slower animation does not get them pulled out from under it halfway through.

dismiss() hides rather than removes, so a status line built into a form survives to report the next attempt. A notification inside a region is removed by that region once it has faded.

A form's single status line is the common case:

<jts-notification id="status" hidden></jts-notification>
status.show("That passkey was not accepted.", "error")
status.show("Passkey added.", "success")
status.clear()

<jts-notifications>

A fixed region that stacks the ones a page raises while it is running.

<jts-notifications position="bottom-right" max="3"></jts-notifications>

| Attribute | Default | Description | |---|---|---| | position | bottom-right | top-right, top-left, top-center, bottom-right, bottom-left, bottom-center | | animation | slide | The entrance and exit for the whole stack | | max | 4 | How many may be on screen; the oldest go first. 0 for no cap | | label | "Notifications" | Accessible name for the region |

The newest is always nearest the edge the stack is docked to, and a sliding notification always arrives from off-screen — bottom-docked stacks come up from below, top-docked ones drop down from above.

The region is not itself a live region — each notification carries its own role, and nesting them makes some screen readers announce a message twice.

notify

notify finds the region, or creates one on first use. Put a <jts-notifications> in the page yourself and it will be used instead, which is all it takes to move the stack.

notify.error("Could not reach the payment provider.")   // stays until dismissed
notify.warning("This project archives in 6 days.")      // stays until dismissed
notify.success("Saved.")                                // 6s
notify.info("A new version is available.")              // 6s
notify("Something happened.", { variant: "info", duration: 10000 })
notify.clear()

Failures do not expire on their own: a message about work that did not happen should not disappear while the person is still reading it.

Motion

| Name | What it does | |---|---| | slide (default) | Comes in from off-screen with a fade | | bounce | The same, overshooting the resting position and settling back | | fade | Opacity only | | scale | Fades in from 92% | | none | Appears |

Set it on the region for the whole stack, or on one notification to override it:

<jts-notifications animation="bounce"></jts-notifications>
<jts-notification animation="fade" variant="info">Just this one.</jts-notification>
notify.success("Saved.", { animation: "bounce" })

Your own animation is a stylesheet away. The names above are only presets that point --jts-notification-enter at keyframes; a project can point it anywhere, and nothing in the library needs to know:

jts-notification[data-animation="swing"] {
    --jts-notification-enter: swing-in;
    --jts-notification-exit: swing-out;
    --jts-notification-enter-duration: 420ms;
}

@keyframes swing-in {
    from { rotate: -6deg; translate: 40px 0; opacity: 0; }
    to   { rotate: 0deg; translate: 0 0; opacity: 1; }
}
notify.info("Custom motion.", { animation: "swing" })

The element waits for animationend rather than a hard-coded duration, so a slower animation is simply slower. Under prefers-reduced-motion nothing animates and dismissal is immediate.

Styling

The defaults are deliberately plain. Paint tokens fall back to the browser's own system colours — Canvas, CanvasText, ButtonBorder — which follow the user's light or dark preference, so the component is legible anywhere without this library taking a position on what your site should look like.

The tokens come in two groups. Structure is geometry and rarely needs touching. Paint is what a project replaces, usually in one block:

:root {
    --jts-notification-bg: var(--bg-lighter);
    --jts-notification-color: var(--text);
    --jts-notification-outline: var(--bg-lightest);
    --jts-notification-accent: var(--text-secondary);
    --jts-notification-accent-error: var(--error);
    --jts-notification-accent-warning: var(--warning);
    --jts-notification-accent-success: var(--success);
    --jts-notification-accent-info: var(--primary);
}

The variant is carried by a 3px border, not a background tint: it reads at any contrast setting and does not depend on telling two similar hues apart.

| Group | Tokens | |---|---| | Structure | max-width, padding, gap, radius, font-size, line-height, accent-width, dismiss-size | | Paint | bg, color, outline, accent, accent-error, accent-warning, accent-success, accent-info | | Motion | enter, exit, enter-duration, exit-duration, enter-easing, exit-easing, slide-from | | Region | --jts-notifications-gap, -offset, -width, -z-index |


Connections

A sequence diagram that lights itself up in order: boxes appear, the paths between them draw, and the trail behind fades as the front runs on.

<jts-connections>
    <div data-order="0">Client</div>
    <svg><path data-order="1" d="…" /></svg>
    <div data-order="2">Server</div>
</jts-connections>

Steps are whatever carries data-order, sorted by that number — the sequence is the order you wrote, so a numbering with gaps in it still works. An SVGPathElement is treated as a line and gets its own length in --length, which is what lets a stylesheet draw it:

jts-connections path {
    stroke-dasharray: var(--length);
    stroke-dashoffset: var(--length);
}
jts-connections path.active {
    stroke-dashoffset: 0;
}

| Attribute | Default | Description | |---|---|---| | box-duration | 300 | Milliseconds for a box to appear | | line-duration | 800 | Milliseconds for a path to draw | | hold | 1800 | Milliseconds a finished run rests before clearing |

No stylesheet ships with it: the diagram is your markup, and what active and unactive look like is yours to decide. It cycles only while on screen, stops when removed from the document, and under prefers-reduced-motion it shows the finished diagram rather than looping.


Smooth scroll

Wheel-driven smooth scrolling for the page. Not an element — there is one document, so there is one of these.

import "@jts-studios/web-components/smooth-scroll"     // starts it
import { SmoothScroll } from "@jts-studios/web-components"

const scroller = new SmoothScroll({ inertia: 0.18, maxDelta: 400 })

| Option | Default | Description | |---|---|---| | inertia | 0.12 | Fraction of the remaining distance covered per frame | | maxDelta | 500 | Largest single wheel step honoured, in pixels | | nativeSelector | "[data-native-scroll]" | Elements whose own scrolling is left alone |

Mark anything with its own scroller so the page does not move underneath it:

<pre data-native-scroll>…</pre>

It reads layout only when the document resizes — a ResizeObserver, not a scrollHeight read per wheel event — stops its loop the moment it is within a pixel of the target, and detaches entirely for a touch device or for somebody who has asked for reduced motion, where the native behaviour is better than anything this can fake. destroy() puts everything back.


Building blocks

Both primitives behind the carousel are exported for building further components.

import { subscribe, onScrollFrame, DragController } from "@jts-studios/web-components"

// Shared rAF loop — one callback per frame for the whole page.
const stop = subscribe((delta, now) => { /* ... */ })

// Shared passive scroll listener — one for the page, rAF-throttled, with the
// position already read so your callback never touches layout.
const unwatch = onScrollFrame((scrollY, delta) => { /* ... */ })

// Pointer drag with velocity tracking.
const drag = new DragController(element, {
    onMove: ({ x, y }) => { /* ... */ },
    onEnd: ({ velocity }) => { /* px per second */ },
})

Development

npm install     # library
npm run build   # bundle to dist/

cd test
npm install     # playground, aliased to the library source
npm run dev