@jts-studios/web-components
v0.9.0
Published
Performance-first, framework-free UI components by JTS Studios
Maintainers
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-componentsImport 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 elementElements 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.
pointermovebanks its delta and the frame applies it; widths are only re-read whenResizeObserverreports 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 itimport { 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