@videsigns/kit
v2.1.3
Published
ViDesigns Webflow behaviour library — modular, attribute-loaded, jsDelivr-served.
Downloads
710
Readme
VDS — Webflow Behaviour Library
A single, modular custom-code library for Webflow starter-kit and client projects. One script tag, served from jsDelivr, loads only the modules a given site flags via attributes. Behaviour attaches to broad data-attributes (vds-tabs, vds-slider, …) rather than component-specific classes, so one module drives unlimited components.
This document is the contract: it defines the attribute vocabulary, the module map, and what each module expects in the DOM. Build to this spec; treat divergence from it as a bug.
Namespace note: the prefix is
vds-(from ViDesigns, matching the prefix you used in your first sketch). It's trivial to rename later — it's a single constant in the build — so don't over-think it now.
1. How it loads
One script tag goes in the Webflow project's footer custom code. The module attributes on the tag decide what runs.
<script async type="module"
src="https://cdn.jsdelivr.net/npm/@videsigns/kit@2/dist/vds.js"
vds-tabs
vds-slider
vds-theme
></script>- The
srcis a small loader. It reads its own attributes, then dynamicallyimport()s only the flagged module chunks. Unflagged modules are never fetched or parsed. (See the separate architecture notes for the build/bundling details.) @2is a major-version range: patches and new features auto-propagate to every site without touching the Webflow project. A breaking change ships asv3, which@2sites never pick up. This is the deliberate "auto-update the safe stuff, opt-in to the dangerous stuff" model for the starter kit.- jsDelivr caches floating ranges for up to ~7 days (browser cache stacks on top, ~14 days worst case). The release pipeline purges the jsDelivr URL on publish so updates land in minutes, not weeks.
async type="module"keeps the script non-render-blocking. Exception: thethememodule — see §3.6 for the flash-of-wrong-theme handling, which needs a tiny synchronous snippet placed as the first element inside<body>(not deferred).
A module is active when its attribute is present on the script tag. Within the page, behaviour then attaches to any matching component automatically — every module is querySelectorAll-based and initialises all instances.
2. Attribute conventions
A consistent grammar across all modules:
| Form | Meaning | Example |
|---|---|---|
| vds-<module> on the script tag | Load this module | vds-slider |
| vds-<module> on a component root | This element is a <module> instance | <div vds-slider> |
| vds-<module>="<value>" on a root | Optional style/skin selector (CSS only; JS never branches on it) | vds-tabs="layout-5" |
| vds-<module>-<option>="<value>" on a root | A config option | vds-tabs-interval="6000" |
| vds-<module>-element="<role>" on a child | A structural part of the component | vds-slider-element="track" |
Two structural targeting rules, applied consistently:
- Where a native ARIA pattern exists, that is the structural contract — tabs use
role="tablist" / "tab" / "tabpanel"plusaria-controls; accordion triggers/panels relate viaaria-controls/aria-expanded. State lives in ARIA everywhere (aria-selected,aria-hidden,aria-expanded). - Where no clean ARIA structure exists (slider track/arrows/dots, gallery track, video parts, filter buttons/items), use
vds-<module>-element="<role>".
Value conventions:
- Booleans accept
"true"/"false". Every boolean documents its own default (they are not all default-false — see each module). - Durations are milliseconds unless noted. Marquee speed is in seconds (it reads naturally that way).
- Selectors and key-lists are plain strings / comma-separated lists.
3. Modules
3.0 Shared core (always present)
Modules share a small core (downloaded once and reused). esbuild splits it into a few fine-grained chunks, so a module pulls only the parts it uses — most pull the observer; tabs/slider/accordion also pull the autoplay chunk; theme pulls none. It standardises the behaviours that were previously copy-pasted — and subtly inconsistent — across six scripts:
- Autoplay controller. One implementation behind tabs, slider, and accordion autoplay. Consistent everywhere: pauses on hover (default on), pauses on keyboard focus (modality-aware — a mouse click never leaves a component stuck paused), pauses when the tab is hidden, and only runs while the component is in the viewport. Manual interaction resets the timer (or hard-stops it, per option). Pause/resume preserves elapsed time so the progress bar and timer stay in sync.
- Reduced motion. All transitions and all autoplay respect
prefers-reduced-motion: reduce(disables autoplay, collapses animation). Applied uniformly — previously only some scripts honoured it. - Init timing. Runs after
DOMContentLoadedand afterWebflow.push(...)settles, so it doesn't fight Webflow's own interactions. - CMS / dynamic DOM. One debounced
MutationObserver, scoped as tightly as possible, that re-inits newly injected components and disconnects after a settle window. This replaces the two unthrottled page-wide observers in the current code (header-offset and utm), which forced layout / rewrote every link on every mutation. - Idempotent init. Modules guard against double-initialisation. Multiple instances per page are always supported.
Config options listed per module below are the per-component overrides; the autoplay defaults above are the shared baseline.
3.1 tabs
Absorbs layout-3, layout-4, layout-5, and the VI pill tabs. One engine that never branches on the skin name. Icon reveal is pure CSS (off aria-selected); the progress fill, sliding pill, and edge-bleed are opt-in capability flags (vds-tabs-progress, vds-tabs-pill, vds-tabs-breakout) — pill and breakout involve JS measurement, the rest is CSS.
Activate: vds-tabs on script + on each component root.
Skin (optional): vds-tabs="layout-5" etc. — picks a CSS skin only.
Structure (ARIA):
| Element | Selector |
|---|---|
| Tab list | [role="tablist"] |
| Tab | [role="tab"] with aria-controls="<panelId>" |
| Panel | [role="tabpanel"][id] |
Active state is written to aria-selected (tabs) and aria-hidden (panels). No display:none on panels — visibility is via the attribute hooks so transitions work.
Options (root):
| Attribute | Type | Default | Notes |
|---|---|---|---|
| vds-tabs-autoplay | bool | false | auto-advance |
| vds-tabs-interval | ms | 5000 | |
| vds-tabs-animation | none\|slide-down\|carousel\|fade | slide-down | |
| vds-tabs-progress | bool | false | show progress fill on active tab |
| vds-tabs-pause-on-hover | bool | true | |
| vds-tabs-pause-on-focus | bool | true | keyboard-modality-aware |
| vds-tabs-stop-on-interaction | bool | false | hard-stop vs reset on click |
| vds-tabs-viewport-threshold | float | 0.35 | autoplay only when this fraction is visible |
| vds-tabs-breakout | bool | false | layout-5 edge-bleed measuring (opt-in; not inferred from skin) |
| vds-tabs-pill | bool | false | measured sliding-pill highlight (VI tabs); JS positions it via CSS vars |
CSS hooks: --vds-tabs-interval (progress duration), [aria-selected], [aria-hidden], [data-tabs-dir="next\|prev"] (carousel direction), --vds-tabs-breakout-left/right, and (when vds-tabs-pill) a generated [vds-tabs-element="pill"] positioned via --vds-tabs-pill-x / --vds-tabs-pill-w on the tablist.
Keyboard: arrows / Home / End move focus; Enter / Space activate. Roving tabindex.
3.2 slider
Absorbs the single-slide and 3-card sliders. They were the same engine with perView = 1; per-view now comes from CSS, so one module covers both and any other count.
Activate: vds-slider on script + on each component root.
Structure:
| Element | Selector | Required |
|---|---|---|
| Track | vds-slider-element="track" | yes |
| Slide | vds-slider-element="slide" | yes (≥2) |
| Prev / Next | vds-slider-element="prev" / "next" | optional |
| Dots wrap | vds-slider-element="dots" | optional (dots auto-generated) |
Per-view is CSS-driven: set --vds-slides-per-view on the track (default 1). Change it at breakpoints in CSS; JS rebuilds clones on change. This is the single/multi merge.
Options (root):
| Attribute | Type | Default | Notes |
|---|---|---|---|
| vds-slider-loop | bool | true | clone-based infinite loop |
| vds-slider-swipe | bool | true | touch swipe |
| vds-slider-autoplay | bool | false | |
| vds-slider-interval | ms | 4000 | |
| vds-slider-pause-on-hover | bool | true | standardised to true (was false in old code) |
| vds-slider-avatars | bool | false | opt-in avatar-thumbnail sync add-on |
Avatar add-on (when enabled): thumbnails carry vds-slider-element="avatar" and vds-slider-avatar-index="<n>"; active gets .is--current.
CSS hooks: --vds-slides-per-view, .is-animating on track, .is-active on dots; clones are marked with the data-vds-clone attribute (target [data-vds-clone]).
Slide sizing is the CSS's job. The module reads
--vds-slides-per-viewonly to know which slide to move to; it does not size the slides. For any multi-per-view slider you must give each slide its width from the same variable, and the between-slidegapyou set must be the same value the width calc subtracts (use one gap source — the track'sgap— or the row overflows):.my_track { display: flex; gap: var(--slide-gap, 1.5rem); } .my_track > [vds-slider-element="slide"] { flex: 0 0 auto; box-sizing: border-box; width: calc((100% - (var(--vds-slides-per-view) - 1) * var(--slide-gap,1.5rem)) / var(--vds-slides-per-view)); }Single-slide sliders (per-view 1) need none of this.
3.3 accordion
The FAQ accordion and the testimonial-7 rotator — they're the same single-active-with-optional-autoplay machine. Rotator = accordion with autoplay + single + always-open.
Activate: vds-accordion on script + on each component root.
Structure:
| Element | Selector |
|---|---|
| Item | vds-accordion-element="item" |
| Trigger (button) | vds-accordion-element="trigger" — gets aria-expanded, aria-controls |
| Panel | vds-accordion-element="panel" — gets role="region", aria-labelledby |
Options (root):
| Attribute | Type | Default | Notes |
|---|---|---|---|
| vds-accordion-single | bool | false | one open at a time |
| vds-accordion-duration | ms | 300 | open/close animation |
| vds-accordion-always-open | bool | false | a click can't close the last-open item (rotator behaviour) |
| vds-accordion-autoplay | bool | false | auto-advance through items |
| vds-accordion-interval | ms | 4000 | |
Item initial state: vds-accordion-open="true" on an item opens it on load.
CSS hooks: .is-open on item, [aria-expanded], [hidden] on collapsed panels (when not animating).
Note: this fixes the current testimonial-7 bug where only the first component on a page initialises (
querySelector→querySelectorAll).
3.4 gallery
The drag-with-momentum slider and the marquee. Kept separate from slider because free-drag and infinite marquee are genuinely different from slide-snap, though it still consumes the shared core.
Activate: vds-gallery on script + on each component root.
Structure: vds-gallery-element="track" wrapping the items. Optional [aria-live] region for slider-mode announcements.
Options (root):
| Attribute | Type | Default | Notes |
|---|---|---|---|
| vds-gallery-mode | slider\|marquee | slider | runtime-switchable |
| vds-gallery-speed | seconds | 24 | marquee loop duration (was hardcoded in CSS) |
CSS hooks: --vds-marquee-shift and --vds-marquee-duration (both set by JS — your marquee CSS must read animation-duration: var(--vds-marquee-duration, 24s) and translate by var(--vds-marquee-shift), or vds-gallery-speed has no effect), .is-dragging (slider drag), mode selector [vds-gallery-mode="marquee"]. Clones get [data-vds-clone].
3.5 filter
The blog category filter. Generalised and made robust: matching is on explicit values, not scraped tag text or button labels (the current approach is fragile and not CMS-safe).
Activate: vds-filter on script + on each component root.
Structure:
| Element | Selector | Notes |
|---|---|---|
| Filter button | vds-filter-element="button" (+ optional vds-filter-value) | value from vds-filter-value, else the button's own label text |
| Item | vds-filter-element="item" | a filterable item |
| Item categories (option A) | vds-filter-category="marketing, design" on the item | explicit comma list (static / single-value CMS) |
| Item categories (option B) | nested vds-filter-element="tag" elements inside the item | reads their text — for Webflow multi-reference tag lists |
| Empty state | vds-filter-element="empty" | shown when nothing matches |
Options (root):
| Attribute | Type | Default | Notes |
|---|---|---|---|
| vds-filter-match | any\|all | any | OR vs AND across active filters |
CSS hooks: .is-active on buttons, [aria-pressed], [hidden] on filtered-out items.
3.6 theme
Light/dark. The one render-affecting module, so it needs special handling to avoid a flash of the wrong theme (FOUC).
Designed for Webflow Variable Modes. The dark class is applied to <body> by default, because Webflow generates its Dark variable-mode CSS scoped to the body element (you can't apply a variable mode to <html> in the Designer). So body.is-dark is what flips your variable collection to Dark and re-colours the page. Do not move this to <html> unless you've abandoned Webflow variable modes and hand-write your dark token values in custom CSS.
Two parts:
- Pre-paint snippet (synchronous, not deferred) — a ~10-line blocking script placed as the first element inside
<body>(an HTML Embed, ideally in a global symbol on every page). It reads forced/stored/system preference and setsbody.is-darkbefore the page content paints. It must sit at the top of the body, not the<head>(where<body>doesn't exist yet) and not the footer (too late). This is the only inline code the kit requires. Seesnippets/theme-inline.html. thememodule (deferred, in the bundle) — wires up the toggles, system-change listener, and storage after load. The class is already correct from step 1, so there's no flash.
Activate: vds-theme on script.
Controls (in page):
| Attribute | Element |
|---|---|
| vds-theme-toggle | toggle button (gets aria-checked) |
| vds-theme-auto | "reset to system" control |
Options (on the <script> tag):
| Attribute | Type | Default | Notes |
|---|---|---|---|
| vds-theme-default | system\|light\|dark | system | initial when nothing stored |
| vds-theme-force | light\|dark | — | build-time lock; ignores system/storage/toggles (replaces the FORCE_THEME string flag) |
| vds-theme-storage-key | string | theme | |
| vds-theme-class | string | is-dark | class applied for dark |
| vds-theme-target | body\|html | body | body drives Webflow variable modes (default). html only for non-variable-mode setups that style off :root/html. |
If you change
vds-theme-force/vds-theme-storage-key/vds-theme-classon the script tag, mirror the same values in the pre-paint snippet's consts so the two agree.
3.7 utm
Persists UTM + click-ID params in sessionStorage and applies them to internal links, hidden form fields, and optional external CTAs. Page-global, so configured on the script tag, not a component.
Activate: vds-utm on script.
Options (script tag):
| Attribute | Type | Default | Notes |
|---|---|---|---|
| vds-utm-keys | comma list | the 8 standard keys¹ | params to track |
| vds-utm-attribution | first\|last | last | first-touch vs last-touch |
| vds-utm-storage-key | string | vd_persisted_params_v1 | |
| vds-utm-internal-links | bool | true | append params to internal <a> |
| vds-utm-forms | bool | true | fill hidden inputs named like the keys |
| vds-utm-cta-selector | string | — | external CTA buttons to rewrite (opt-in) |
| vds-utm-cta-base | url | — | base URL for those CTAs |
¹ utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, fbclid, msclkid
Performance fix vs current code: link decoration no longer runs a page-wide
MutationObserveron every DOM change. It decorates on load and re-decorates via the shared debounced observer (and lazily on click for late-added links).
3.8 video
Generalised from testimonial-9 to any video: click-to-play with overlay, native controls on play, overlay restored on end.
Activate: vds-video on script + on each wrapper.
Structure:
| Element | Selector |
|---|---|
| Wrapper | vds-video (root) |
| Play trigger | vds-video-element="trigger" |
| Overlay | vds-video-element="overlay" |
| Video | vds-video-element="player" (or a <video> inside) |
No config beyond presence.
3.9 site (text + condition cleanup + header offset)
The global bits that don't belong to a component. Split so they can be toggled independently if wanted.
vds-text — text injection into [vds-text="<key>"]. year is a built-in dynamic token (current year). Arbitrary values (company name, review score/count) are read from a site-config object set once per project:
<script>window.VDS_TEXT = { company: "ViDesigns", "review-score": "4.5", "review-count": "129" };</script>Also removes Webflow's .w-condition-invisible empty-state nodes.
vds-header-offset — sets --header-h to the max bottom edge of nav/banner.
| Attribute | Type | Default |
|---|---|---|
| vds-header-offset-nav | selector | .navbar_container, .w-nav |
| vds-header-offset-banner | selector | .banner_container |
| vds-header-offset-var | string | --header-h |
Uses the shared debounced observer rather than the current unthrottled one.
4. Module map (summary)
| Module | Script attr | Replaces |
|---|---|---|
| tabs | vds-tabs | layout-3, layout-4, layout-5, VI pill tabs |
| slider | vds-slider | single-slide slider, 3-card slider |
| accordion | vds-accordion | FAQ accordion, testimonial-7 rotator |
| gallery | vds-gallery | drag-slider + marquee gallery |
| filter | vds-filter | blog category filter |
| theme | vds-theme | light/dark toggle |
| utm | vds-utm | param persistence / link & form decoration |
| video | vds-video | testimonial-9 video control |
| site | vds-text, vds-header-offset | global text inject, condition cleanup, header offset |
14 scattered scripts → 10 modules (the site row below is really two: text + header-offset) + a shared core (split into a few small chunks).
5. Migration map (old → new)
For reference when porting existing components onto the new contract.
| Old | New |
|---|---|
| data-tabs="layout-3\|4\|5", data-vi-tabs | vds-tabs (+ optional skin value) |
| data-layout3-auto, data-tabs-auto, data-tabs-auto-rotate | vds-tabs-autoplay |
| data-layout3-interval, data-tabs-interval | vds-tabs-interval |
| data-slider, data-slider-multi | vds-slider (+ --vds-slides-per-view) |
| data-loop, data-autoplay, data-autoplay-delay, data-pause-on-hover | vds-slider-loop, vds-slider-autoplay, vds-slider-interval, vds-slider-pause-on-hover |
| [data-slider-prev/next], .slider_track, .slider_slide, .slider-dots | vds-slider-element="prev/next/track/slide/dots" |
| [data-accordion-item/trigger/panel] | vds-accordion-element="item/trigger/panel" |
| .testimonial7_component (rotator) | vds-accordion + autoplay + single + always-open |
| data-gallery-mode | vds-gallery-mode |
| .blog_tabs + scraped tag/label text | vds-filter + vds-filter-value / vds-filter-category |
| data-theme-toggle, data-theme-auto, FORCE_THEME | vds-theme-toggle, vds-theme-auto, vds-theme-force |
| .testimonial9_* | vds-video-element="…" |
| [data-text] | vds-text + window.VDS_TEXT |
6. Versioning & releases (summary)
- Semantic versioning. Patches/minors are backward-compatible and auto-propagate via the
@2range. - Breaking changes → new major (
v3); existing sites stay on@2until deliberately migrated. - CI on a version tag: build → publish to npm → purge jsDelivr URLs.
- Test on a canary site before publishing; a bad release reaches all
@2sites once the cache turns over.
(Full build/bundling and CI details live in the architecture notes, written next.)
