Integrating JavaScript Libraries
Ship third-party JavaScript libraries with your components
Sooner or later a component needs more JavaScript than you want to write yourself — a carousel engine, a lightbox, a scroll-animation library, a physics engine. The question is never whether you can use a third-party library in Elements; it's the three questions that follow: where do the library files live, how does the library load exactly once no matter how many components depend on it, and how does each component instance bind to the single loaded copy? This guide answers all three, then covers the special case of using an npm library inside hooks.js, where there is no module system at all.
This guide dissects the Reveal (com.realmacsoftware.reveal) and Gallery (com.realmacsoftware.gallery) components from the open-source Core Pack. Open their source alongside this page.
Where Library Files Live
A library file has three possible homes, and the choice shapes everything downstream — how you reference it, how many copies ship, and what happens on publish:
Component assets/page/
Once per publish, into the page's files directory
{{assetPath}} — a prop you set from rw.component.assetPath in hooks.js
Exactly one component needs the library
Pack shared/assets/
Once per pack
{{assetPath}} (automatic) in shared templates; rw.component.sharedAssetPath from hooks
Several components in your pack share the library
CDN
Not deployed — loaded from a third-party server
An absolute URL inside a portal
Prototyping, or libraries whose license forbids redistribution
The trade-offs pull in different directions. Component-level assets keep the library scoped to the one component that needs it, but if three of your components each vendor the same carousel engine, three copies ship — and unless all three deduplicate their loaders with the same portal id, three copies load. Pack-level shared assets hold one copy for the whole pack, but a library referenced from a shared template loads on every page that uses any component from your pack, whether that page needs it or not. CDNs add zero weight to your pack but make every published site depend on a third-party server — more on that below.
{{assetPath}} is one spelling with two meanings. Inside a shared template, it resolves automatically to your pack's shared/assets/ folder — see Shared Assets. Inside a component template, nothing is automatic: it's an ordinary prop you must set yourself from rw.component, and you can name it anything — the Core Pack's Content Slider calls it componentAssetPath to avoid ambiguity.
This is exactly how the Core Pack organises its own dependencies. Its shared/assets/ folder carries the libraries that many core components lean on:
shared/assets/ (Core Pack)
├── alpine.js # Alpine v3 core
├── alpine-collapse.js # …plus official Alpine plugins
├── alpine-focus.js
├── alpine-intersect.js
├── alpine-ui.js
├── gsap.min.js # GSAP core
├── ScrollTrigger.min.js # GSAP scroll plugin
├── gsap-effects.js # Realmac's registered GSAP effects
├── image-lightbox.js # a shared Alpine factory (see below)
├── smoothscroll.min.js
└── …Meanwhile the Content Slider — the only core component that needs Swiper — vendors swiper-bundle.min.js and swiper-bundle.min.css in its own assets/page/ folder. One library, one consumer, component-level; one library, many consumers, pack-level. Remember that page is the only supported subfolder for component assets — see Assets.
Loading a Library Exactly Once
There are two mechanisms for getting a <script> tag onto the page a single time, and they differ in when the cost is paid.
Pack-Shared Templates
Files in shared/templates/headStart|headEnd|bodyStart|bodyEnd/ are injected automatically, once per page per pack, whenever any component from your pack appears on that page — no portal, no includeOnce, no opt-in from individual components. This is how the Core Pack puts GSAP and Alpine on every page:
A second shared template at the other end of the document consumes what the first one loaded. This is the Reveal system's engine — a single script, once per page, that wires GSAP's ScrollTrigger to every reveal-enabled element:
Note the position pairing: the library loads in headStart (so it exists before anything else runs), the consumer sits in bodyEnd (so the DOM exists before it queries). The shared templates reference documents all four injection points.
The cost of this convenience: every page that uses even one trivial component from your pack carries every library your shared templates load. Reserve shared templates for libraries most of your pack genuinely needs — the Core Pack can justify page-wide GSAP because dozens of its components use it.
Per-Component Portals with includeOnce
When only one or two components need a library, load it from the component's own template with @portal, and make the portal deduplicate itself:
Two rules are non-negotiable. First, any portal containing a <script> needs includeOnce: true and a reverse-DNS id — without them, ten sliders on a page emit ten copies of Swiper. Second, if two different components vendor the same library, give both loader portals the same id so the page gets one copy regardless of which components appear. And because an includeOnce portal is emitted for the first instance only, never interpolate per-instance property values inside one — the Alpine guide explains the failure mode.
The economics are the inverse of shared templates: the library costs nothing on pages that don't use the component, and exactly one copy on pages that do.
Binding Instances After the Library Loads
A loaded library is inert until something calls it — and that something must run after the library exists. The core components use two sequencing patterns, chosen by whether the library is plain JavaScript or Alpine-based.
Plain Libraries: DOMContentLoaded Plus data-* Attributes
Reveal never runs GSAP code per instance. Instead, each instance's hooks.js publishes its configuration as data-reveal-* attributes on the root element — the globalReveal helper from rw-elements-tools computes them from the component's properties:
The once-per-page reveal.html script you saw above does the rest: on DOMContentLoaded it queries [data-reveal], reads each element's dataset, and builds the ScrollTrigger animations. The instances and the library never touch — instances publish data, the single consumer script reads it. This decoupling is why the pattern scales: adding an eleventh reveal-enabled element adds eleven attributes to the page, not another script.
Alpine Factories: alpine:init Plus Events Keyed by ID
For Alpine-based integration the sequencing event is alpine:init, which fires after Alpine loads but before it scans the page for x-data. The Gallery's lightbox is the full worked example. Its factory registers once through a deduplicated portal:
Each Gallery instance then connects itself to the shared factory by passing its own ID into x-data, and its thumbnails talk to the lightbox through events that carry the same ID:
(The transition and x-effect attributes on the wrapper are elided here.) Note the lightbox markup itself also travels through a portal to bodyEnd — a separate overlay-positioning concern covered in Modals, Overlays & Portals.
The factory filters every event against this.id, so five galleries on one page stay independent while sharing one copy of the logic. When a factory serves multiple components rather than multiple instances of one, promote it to a shared asset: the Core Pack's shared/assets/image-lightbox.js registers an imageLightbox factory the same way — document.addEventListener("alpine:init", () => { Alpine.data("imageLightbox", (id) => ({ … })) }) — and is loaded once per page by a shared headEnd template, ready for any component that wants a lightbox. The factory mechanics themselves are covered in depth in Interactive Components with Alpine.js.
A Complete Worked Pattern: Vendoring a Carousel
Here is the whole technique in one generic component, com.example.carousel, wrapping an invented library whose API is window.Carousel.create(el, options). The files:
The hook exposes the asset path and packages the instance configuration as JSON:
The markup carries the per-instance config in a single-quoted data-* attribute and stays completely inert in the edit canvas:
And one template file owns the component's entire page-level footprint — stylesheet to the head, library plus factory to the end of the body, everything deduplicated:
Every moving part earns its place: the library <script> sits inside the same includeOnce portal as the factory, so they load together or not at all and their order is guaranteed; the factory reads its options back from data-carousel-config in init(), so per-instance values never leak into the once-only portal; and destroy() tears the library instance down when Alpine removes the element, mirroring how the Core Pack's Content Slider calls this.swiper.destroy().
Loading from a CDN
Sometimes vendoring isn't an option — the library's license forbids redistribution, or you're prototyping and don't want to manage files yet. A CDN load uses the same portal discipline, with a pinned version in the URL:
A CDN reference makes every published site depend on a server neither you nor your users control. If the CDN is down, blocked (corporate networks, some countries), or the URL scheme changes, the published site breaks — and the site owner will blame the component. Pin exact versions (never @latest, which can ship breaking changes into live sites), and remember the privacy angle: every visitor's browser contacts the third-party host, which may need disclosure under privacy regulations. For anything you sell, vendored assets are the reliable default.
Using Libraries Inside hooks.js
Everything so far ships libraries to the published page. Using a library inside hooks.js — at build time, while Elements renders your component — is a different problem, because the hooks runtime has no module system: no require, no import, no npm resolution. A hooks.js file must be entirely self-contained.
The way to use an npm package anyway is a pre-build step that bundles it into hooks.js. This fits the convention you'll already have from rw-elements-tools: you author hooks.source.js, a compile step produces the hooks.js that ships, and both files sit side by side in the component (as they do throughout the Core Pack). Suppose your source uses a hypothetical slugify package:
One esbuild invocation inlines the entire package and emits a self-contained file:
The generated hooks.js contains the full slugify implementation followed by your unchanged transformHook — no imports left, nothing to resolve at run time. Re-run the script whenever the source changes, and treat the output as generated code.
Two constraints to respect. First, the Elements hooks runtime is a plain JavaScript sandbox: no window, no document, and no Node globals (process, Buffer, fs, timers). Prefer pure-computation libraries — slug generators, parsers, formatters, hash functions. --platform=neutral keeps esbuild from assuming either environment, but it won't save a library that probes for process at load time; for those, either define minimal stubs ahead of the bundled code or pick a dependency-free alternative. Second, bundle per component — each hooks.js stands alone, so a package shared by three components is inlined three times. That's the correct trade: build-time file size is cheap, a broken runtime resolution is not.
Cleanup and Lifecycle Hygiene
A library instance you create is a library instance you own. Give every Alpine factory that wraps a library a destroy() that mirrors its init() — tear down the instance, cancel animation loops, and remove any window-level listeners you added, or removed components leak them. Restore state on pageshow when event.persisted is true, because browsers revive pages from the back/forward cache with your JavaScript frozen mid-flight. And gate motion-heavy libraries behind prefers-reduced-motion, honouring changes mid-session. The ticker example in the Alpine guide demonstrates the complete checklist line by line — every library integration you ship should pass it.
Related Documentation
Assets — component-level
assets/page/and the{{assetPath}}hook wiringShared Assets — pack-level asset storage and referencing
Shared Templates — the four injection points and once-per-page behaviour
The
@portalDirective — destinations,includeOnce, and deduplication IDsInteractive Components with Alpine.js — the factory pattern, configuration passing, and lifecycle discipline
Build Tools — the
hooks.source.js→hooks.jsworkflow and shared hooks likeglobalReveal
Last updated
Was this helpful?

