UNPKG

@magic-spells/scrolling-content

Version:

Infinite scrolling marquee web component — hover to pause, drag to scrub, no dependencies

353 lines (352 loc) 12.8 kB
//#region src/scrolling-content.css?inline var scrolling_content_default = "scrolling-content:not(:defined) {\n visibility: hidden;\n}\n\nscrolling-content {\n display: block;\n overflow: hidden;\n}\n\nscrolling-content[fade] {\n --_fade: var(--scrolling-content-fade, 4rem);\n -webkit-mask-image: linear-gradient(90deg,\n transparent,\n #000 var(--_fade),\n #000 calc(100% - var(--_fade)),\n transparent);\n -webkit-mask-image: linear-gradient(90deg,\n transparent,\n #000 var(--_fade),\n #000 calc(100% - var(--_fade)),\n transparent);\n mask-image: linear-gradient(90deg,\n transparent,\n #000 var(--_fade),\n #000 calc(100% - var(--_fade)),\n transparent);\n}\n\nscrolling-track {\n align-items: center;\n gap: var(--scrolling-content-gap, 1rem);\n will-change: transform;\n touch-action: pan-y;\n cursor: grab;\n flex-wrap: nowrap;\n width: max-content;\n display: flex;\n}\n\nscrolling-content[drag=\"false\"] scrolling-track {\n cursor: auto;\n touch-action: auto;\n}\n\nscrolling-content[dragging] scrolling-track {\n cursor: grabbing;\n -webkit-user-select: none;\n user-select: none;\n}\n\nscrolling-item {\n align-items: center;\n gap: var(--scrolling-content-gap, 1rem);\n padding: var(--scrolling-content-item-padding, 0);\n flex: none;\n display: flex;\n}\n"; //#endregion //#region src/scrolling-content.js function injectStyles() { if (typeof document === "undefined") return; if (document.querySelector("style[data-scrolling-content]")) return; const style = document.createElement("style"); style.setAttribute("data-scrolling-content", ""); style.textContent = `@layer scrolling-content {\n${scrolling_content_default}\n}`; document.head.prepend(style); } var DEFAULTS = { speed: 60, direction: "left" }; var MAX_DELTA_MS = 64; var FILL_RATIO = 2; var MIN_ITEM_WIDTH = 1; var MAX_ITEMS = 200; var LEGACY_ATTRIBUTES = [ "mobile-speed", "desktop-speed", "breakpoint" ]; var legacyWarned = false; /** * Track element — the flex row that gets translated. Layout lives entirely in * scrolling-content.css so authors can override it without `!important`. */ var ScrollingTrack = class extends HTMLElement {}; /** * Item element — one repeatable unit of content. The component wraps loose * children in one of these and clones it to fill the track. */ var ScrollingItem = class extends HTMLElement {}; /** * Infinite scrolling marquee. * * Attributes: * speed — pixels per second (default 60). Overridden by the * `--scrolling-content-speed` custom property, which lets a * media or container query change speed at a breakpoint. * direction — "left" (default) | "right" * paused — boolean; reflected, and the state `start()`/`stop()` set * pause-on-hover — "false" to opt out (default on) * drag — "false" to opt out (default on) * * Events (all bubble, all composed): * scrolling-content:start, scrolling-content:stop, * scrolling-content:drag-start, scrolling-content:drag-end */ var ScrollingContent = class extends HTMLElement { #track = null; #items = []; #abortController = null; #resizeObserver = null; #motionQuery = null; #rafId = null; #initialized = false; #running = false; #hoverPaused = false; #dragging = false; #pointerId = null; #previousTime = 0; #offsetX = 0; #dragStartX = 0; #dragStartOffset = 0; #containerWidth = 0; #loopDistance = 0; #speed = DEFAULTS.speed; static get observedAttributes() { return [ "speed", "direction", "paused", "pause-on-hover", "drag", "fade" ]; } connectedCallback() { const _ = this; if (!_.#initialized) { _.#initialized = true; _.#warnLegacyAttributes(); _.#buildDOM(); } _.#applyFade(); _.#attachListeners(); _.#resizeObserver = new ResizeObserver(() => _.refresh()); _.#resizeObserver.observe(_); if (_.#items[0]) _.#resizeObserver.observe(_.#items[0]); } disconnectedCallback() { const _ = this; _.#stopLoop(); _.#abortController?.abort(); _.#abortController = null; _.#resizeObserver?.disconnect(); _.#resizeObserver = null; _.#hoverPaused = false; _.#endDrag(); } attributeChangedCallback(name, previousValue, currentValue) { if (previousValue === currentValue) return; if (!this.#initialized) return; if (name === "speed") this.#speed = this.#resolveSpeed(); if (name === "fade") this.#applyFade(); this.#syncPlayback(); } /** Resume scrolling (clears `paused`). */ start() { this.removeAttribute("paused"); } /** Pause scrolling (sets `paused`). */ stop() { this.setAttribute("paused", ""); } /** * Re-measure the container and content, top up clones, and re-normalize the * offset. Called automatically on resize and content change. */ refresh() { const _ = this; if (!_.isConnected || !_.#track) return; _.#containerWidth = _.getBoundingClientRect().width; _.#speed = _.#resolveSpeed(); _.#fill(); _.#normalizeOffset(); _.#paint(); _.#syncPlayback(); } get speed() { return this.#speed; } set speed(value) { this.setAttribute("speed", String(value)); } get direction() { return this.getAttribute("direction") === "right" ? "right" : DEFAULTS.direction; } set direction(value) { this.setAttribute("direction", value === "right" ? "right" : "left"); } get paused() { return this.hasAttribute("paused"); } set paused(value) { this.toggleAttribute("paused", Boolean(value)); } #warnLegacyAttributes() { if (legacyWarned) return; const found = LEGACY_ATTRIBUTES.filter((name) => this.hasAttribute(name)); if (!found.length) return; legacyWarned = true; console.warn(`<scrolling-content>: ${found.join(", ")} ${found.length > 1 ? "were" : "was"} removed in v2. Use the \`speed\` attribute (px/sec) and override it per breakpoint with the \`--scrolling-content-speed\` custom property.`); } /** * `fade` on its own uses the stylesheet's default width; `fade="3rem"` sets * the width inline so the common case needs no accompanying CSS rule. Any CSS * length works — the value is handed to the cascade, not parsed here. */ #applyFade() { const value = this.getAttribute("fade"); if (value) this.style.setProperty("--scrolling-content-fade", value); else this.style.removeProperty("--scrolling-content-fade"); } #buildDOM() { const _ = this; _.#track = _.querySelector("scrolling-track"); if (!_.#track) { _.#track = document.createElement("scrolling-track"); while (_.firstChild) _.#track.appendChild(_.firstChild); _.appendChild(_.#track); } if (!_.#track.querySelector("scrolling-item")) { const item = document.createElement("scrolling-item"); while (_.#track.firstChild) item.appendChild(_.#track.firstChild); _.#track.appendChild(item); } _.#items = Array.from(_.#track.children); } #attachListeners() { const _ = this; _.#abortController?.abort(); _.#abortController = new AbortController(); const { signal } = _.#abortController; _.addEventListener("mouseenter", () => _.#onHover(true), { signal }); _.addEventListener("mouseleave", () => _.#onHover(false), { signal }); _.#track.addEventListener("pointerdown", (e) => _.#onPointerDown(e), { signal }); _.#track.addEventListener("pointermove", (e) => _.#onPointerMove(e), { signal }); _.#track.addEventListener("pointerup", (e) => _.#onPointerUp(e), { signal }); _.#track.addEventListener("pointercancel", (e) => _.#onPointerUp(e), { signal }); window.addEventListener("resize", () => _.refresh(), { passive: true, signal }); _.#motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); _.#motionQuery.addEventListener("change", () => _.#syncPlayback(), { signal }); } /** * Resolve speed in px/sec. The custom property wins when set, so a breakpoint * can override the attribute. Deliberately not given a default in the * stylesheet — a stylesheet default would always beat the attribute. */ #resolveSpeed() { const custom = getComputedStyle(this).getPropertyValue("--scrolling-content-speed").trim(); const fromCSS = custom === "" ? NaN : parseFloat(custom); if (Number.isFinite(fromCSS)) return fromCSS; const fromAttribute = parseFloat(this.getAttribute("speed")); return Number.isFinite(fromAttribute) ? fromAttribute : DEFAULTS.speed; } /** * Measure one item and clone it until the track covers the container plus a * full loop. Leaves `#loopDistance` at 0 when the content isn't measurable * yet; the ResizeObserver will call back once it is. */ #fill() { const _ = this; const source = _.#items[0]; if (!source) return; const itemWidth = source.getBoundingClientRect().width; if (!(itemWidth >= MIN_ITEM_WIDTH)) { _.#loopDistance = 0; return; } _.#loopDistance = itemWidth + (parseFloat(getComputedStyle(_.#track).columnGap) || 0); const needed = Math.min(Math.ceil(_.#containerWidth * FILL_RATIO / _.#loopDistance) + 1, MAX_ITEMS); for (let i = _.#items.length; i < needed; i++) _.#track.appendChild(_.#cloneItem(source)); _.#items = Array.from(_.#track.children); } /** * Clones are visual filler. They're hidden from assistive tech and taken out * of the tab order, and their ids are stripped so the page doesn't end up * with N copies of every id in the content. */ #cloneItem(source) { const clone = source.cloneNode(true); clone.setAttribute("aria-hidden", "true"); clone.inert = true; clone.removeAttribute("id"); for (const element of clone.querySelectorAll("[id]")) element.removeAttribute("id"); return clone; } /** Fold the offset into (-loopDistance, 0]. */ #normalizeOffset() { const distance = this.#loopDistance; if (!(distance > 0)) { this.#offsetX = 0; return; } this.#offsetX = (this.#offsetX % distance + distance) % distance - distance; } #paint() { if (this.#track) this.#track.style.transform = `translateX(${this.#offsetX}px)`; } get #prefersReducedMotion() { return this.#motionQuery?.matches ?? false; } get #shouldRun() { const _ = this; return _.isConnected && _.#loopDistance > 0 && !_.paused && !_.#hoverPaused && !_.#dragging && !_.#prefersReducedMotion; } /** Single place that decides whether the rAF loop is alive. */ #syncPlayback() { if (this.#shouldRun) this.#startLoop(); else this.#stopLoop(); } #startLoop() { const _ = this; if (_.#running) return; _.#running = true; _.#previousTime = performance.now(); _.#rafId = requestAnimationFrame((timestamp) => _.#tick(timestamp)); _.#emit("start"); } #stopLoop() { const _ = this; if (!_.#running) return; cancelAnimationFrame(_.#rafId); _.#rafId = null; _.#running = false; _.#emit("stop"); } #tick(timestamp) { const _ = this; if (!_.#running) return; const delta = Math.min(timestamp - _.#previousTime, MAX_DELTA_MS) / 1e3; _.#previousTime = timestamp; const step = _.#speed * delta; _.#offsetX += _.direction === "right" ? step : -step; _.#normalizeOffset(); _.#paint(); _.#rafId = requestAnimationFrame((next) => _.#tick(next)); } #onHover(entering) { if (this.getAttribute("pause-on-hover") === "false") return; this.#hoverPaused = entering; this.#syncPlayback(); } get #dragEnabled() { return this.getAttribute("drag") !== "false"; } #onPointerDown(event) { const _ = this; if (!_.#dragEnabled || _.#dragging || !event.isPrimary) return; _.#dragging = true; _.#pointerId = event.pointerId; _.#dragStartX = event.clientX; _.#dragStartOffset = _.#offsetX; _.setAttribute("dragging", ""); _.#syncPlayback(); _.#emit("drag-start"); try { _.#track.setPointerCapture(event.pointerId); } catch {} } #onPointerMove(event) { const _ = this; if (!_.#dragging || event.pointerId !== _.#pointerId) return; _.#offsetX = _.#dragStartOffset + (event.clientX - _.#dragStartX); _.#normalizeOffset(); _.#paint(); } #onPointerUp(event) { if (!this.#dragging || event.pointerId !== this.#pointerId) return; this.#endDrag(); } #endDrag() { const _ = this; if (!_.#dragging) return; if (_.#pointerId !== null && _.#track?.hasPointerCapture(_.#pointerId)) _.#track.releasePointerCapture(_.#pointerId); _.#dragging = false; _.#pointerId = null; _.removeAttribute("dragging"); _.#syncPlayback(); _.#emit("drag-end"); } #emit(name) { this.dispatchEvent(new CustomEvent(`scrolling-content:${name}`, { bubbles: true, composed: true })); } }; injectStyles(); if (!customElements.get("scrolling-track")) customElements.define("scrolling-track", ScrollingTrack); if (!customElements.get("scrolling-item")) customElements.define("scrolling-item", ScrollingItem); if (!customElements.get("scrolling-content")) customElements.define("scrolling-content", ScrollingContent); //#endregion export { ScrollingContent, ScrollingItem, ScrollingTrack };