@absmartly/sdk-plugins
v1.1.2
Published
Collection of plugins for ABsmartly JavaScript SDK
Readme
ABsmartly SDK Plugins
A comprehensive collection of plugins for the ABsmartly JavaScript SDK including DOM manipulation, experiment overrides, cookie management, and web vitals tracking.
Available Plugins
1. DOMChangesPlugin
- Complete DOM Manipulation: All change types including styleRules with pseudo-states
- Smart Exposure Tracking: Cross-variant tracking prevents sample ratio mismatch
- Dynamic Content Support: Pending changes with
waitForElementfor SPAs - React/Vue Compatibility: StyleRules survive re-renders
- Anti-Flicker Support: Hide content until experiments load to prevent visual flash
2. OverridesPlugin
- Query String Overrides: Force experiments via URL parameters
- Cookie Persistence: Server-compatible experiment overrides
- API Integration: Fetch non-running experiments (Full version)
- Development Support: Test experiments in any environment
3. CookiePlugin
- Unit ID Management: Generate and persist user identifiers
- UTM Tracking: Capture and store UTM parameters
- Landing Page Tracking: Track first visits and referrers
- Storage Fallbacks: Cookie → localStorage → memory
4. WebVitalsPlugin
- Core Web Vitals: Track CLS, LCP, FCP, INP, TTFB
- Page Metrics: Network timing, DOM processing, resource counts
- Performance Ratings: Automatic good/needs-improvement/poor classification
- Compression Metrics: Track page size and compression ratios
Installation
npm install @absmartly/sdk-pluginsQuick Start
Basic Usage
import { DOMChangesPlugin, CookiePlugin, WebVitalsPlugin } from '@absmartly/sdk-plugins';
import absmartly from '@absmartly/javascript-sdk';
// Initialize ABsmartly SDK
const sdk = new absmartly.SDK({
endpoint: 'https://your-endpoint.absmartly.io/v1',
apiKey: 'YOUR_API_KEY',
environment: 'production',
application: 'your-app'
});
// Create context
const context = sdk.createContext(request);
// Initialize plugins
const domPlugin = new DOMChangesPlugin({
context: context,
autoApply: true, // Automatically apply changes on init
spa: true, // Enable SPA support
visibilityTracking: true, // Track when changes become visible
variableName: '__dom_changes', // Variable name for DOM changes
debug: true // Enable debug logging
});
// Initialize without blocking
domPlugin.ready().then(() => {
console.log('DOMChangesPlugin ready');
});
// Initialize cookie management
const cookiePlugin = new CookiePlugin({
context: context,
cookieDomain: '.yourdomain.com',
autoUpdateExpiry: true
});
cookiePlugin.ready().then(() => {
console.log('CookiePlugin ready');
});
// Initialize web vitals tracking
const vitalsPlugin = new WebVitalsPlugin({
context: context,
trackWebVitals: true,
trackPageMetrics: true
});
vitalsPlugin.ready().then(() => {
console.log('WebVitalsPlugin ready');
});With Experiment Overrides
The OverridesPlugin enables experiment overrides for internal testing and development. Simply load it before SDK initialization and it will automatically check for and apply any overrides:
import {
DOMChangesPlugin,
OverridesPlugin
} from '@absmartly/dom-changes-plugin';
import absmartly from '@absmartly/javascript-sdk';
// Initialize SDK and create context
const sdk = new absmartly.SDK({ /* ... */ });
const context = sdk.createContext({ /* ... */ });
// Initialize OverridesPlugin - it will automatically check for overrides
const overridesPlugin = new OverridesPlugin({
context: context,
cookieName: 'absmartly_overrides',
useQueryString: true,
queryPrefix: '_exp_',
envParam: 'env',
persistQueryToCookie: true, // Save query overrides to cookie
sdkEndpoint: 'https://your-endpoint.absmartly.io',
debug: true
});
overridesPlugin.ready().then(() => {
console.log('OverridesPlugin ready - overrides applied if present');
});
// Initialize DOMChangesPlugin for all experiments
const domPlugin = new DOMChangesPlugin({
context: context,
autoApply: true,
variableName: '__dom_changes',
debug: true
});
domPlugin.ready().then(() => {
console.log('DOMChangesPlugin ready');
});Override Configuration Options
const overridesPlugin = new OverridesPlugin({
context: context, // Required: ABsmartly context
// Cookie configuration
cookieName: 'absmartly_overrides', // Cookie name (omit to disable cookies)
cookieOptions: {
path: '/',
secure: true,
sameSite: 'Lax'
},
// Query string configuration
useQueryString: true, // Enable query string parsing (default: true on client)
queryPrefix: '_exp_', // Prefix for experiment params (default: '_exp_')
envParam: 'env', // Environment parameter name (default: 'env')
persistQueryToCookie: false, // Save query overrides to cookie (default: false)
// Endpoints
sdkEndpoint: 'https://...', // SDK endpoint (required if not in context)
absmartlyEndpoint: 'https://...', // API endpoint for fetching experiments
// Server-side configuration
url: req.url, // URL for server-side (Node.js)
cookieAdapter: customAdapter, // Custom cookie adapter for server-side
debug: true // Enable debug logging
});Query String Format (New)
Use individual query parameters with configurable prefix:
# Single experiment
https://example.com?_exp_button_color=1
# Multiple experiments
https://example.com?_exp_hero_title=0&_exp_nav_style=2
# With environment
https://example.com?env=staging&_exp_dev_feature=1,1
# With experiment ID
https://example.com?_exp_archived_test=1,2,12345Cookie Format
Cookies use comma as separator (no encoding needed):
// Simple overrides (comma-separated experiments)
document.cookie = 'absmartly_overrides=exp1:1,exp2:0';
// With environment flags (dot-separated values)
document.cookie = 'absmartly_overrides=exp1:1.0,exp2:0.1';
// With experiment ID
document.cookie = 'absmartly_overrides=exp1:1.2.12345';
// With dev environment
document.cookie = 'absmartly_overrides=devEnv=staging|exp1:1.1,exp2:0.1';Format: name:variant[.env][.id] where:
- Experiments are separated by
,(comma) - Values within an experiment are separated by
.(dot) - Environment prefix uses
|(pipe) separator
How Overrides Work
- Query String Priority: Query parameters take precedence over cookies
- Environment Support: Use
envparameter for dev/staging experiments - API Fetching: Non-running experiments are fetched from ABsmartly API
- Context Injection: Experiments are transparently injected into context.data()
- DOM Application: DOMChangesPlugin applies changes from all experiments
DOM Change Types
The plugin supports comprehensive DOM manipulation with advanced features:
Core Change Types
Text Change
{
selector: '.headline',
type: 'text',
value: 'New Headline Text'
}HTML Change
{
selector: '.content',
type: 'html',
value: '<p>New <strong>HTML</strong> content</p>'
}Style Change (Inline)
{
selector: '.button',
type: 'style',
value: {
backgroundColor: 'red', // Use camelCase for CSS properties
color: '#ffffff',
fontSize: '18px'
},
trigger_on_view: false // Control exposure timing
}Style Rules (With Pseudo-States)
{
selector: '.button',
type: 'styleRules',
states: {
normal: {
backgroundColor: '#007bff',
color: 'white',
padding: '10px 20px',
borderRadius: '4px',
transition: 'all 0.2s ease'
},
hover: {
backgroundColor: '#0056b3',
transform: 'translateY(-2px)',
boxShadow: '0 4px 8px rgba(0,0,0,0.2)'
},
active: {
backgroundColor: '#004085',
transform: 'translateY(0)'
},
focus: {
outline: '2px solid #007bff',
outlineOffset: '2px'
}
},
important: true, // default is true
trigger_on_view: true
}Benefits of styleRules:
- Handles CSS pseudo-states properly (hover, active, focus)
- Survives React re-renders through stylesheet injection
- More performant than inline styles for complex interactions
Class Change
{
selector: '.card',
type: 'class',
add: ['highlighted', 'featured'],
remove: ['default']
}Attribute Change
{
selector: 'input[name="email"]',
type: 'attribute',
value: {
'placeholder': 'Enter your email',
'required': 'true'
}
}JavaScript Execution
{
selector: '.dynamic-element',
type: 'javascript',
value: 'element.addEventListener("click", () => console.log("Clicked!"))'
}Element Move
{
selector: '.sidebar',
type: 'move',
targetSelector: '.main-content',
position: 'before' // 'before', 'after', 'firstChild', 'lastChild'
}Element Creation
{
selector: '.new-banner', // For identification
type: 'create',
element: '<div class="banner">Special Offer!</div>',
targetSelector: 'body',
position: 'firstChild',
trigger_on_view: false
}Pending Changes (Elements Not Yet in DOM)
{
selector: '.lazy-loaded-button',
type: 'style',
value: {
backgroundColor: 'red',
color: 'white'
},
waitForElement: true, // Wait for element to appear
observerRoot: '.main-content', // Optional: specific container to watch
trigger_on_view: true
}Perfect for:
- Lazy-loaded content
- React components that mount/unmount
- Modal dialogs
- API-loaded content
- Infinite scroll items
Key Features
Exposure Tracking with trigger_on_view
The trigger_on_view property prevents sample ratio mismatch by controlling when A/B test exposures are recorded:
{
selector: '.below-fold-element',
type: 'style',
value: { backgroundColor: 'blue' },
trigger_on_view: true // Only trigger when element becomes visible
}false(default): Exposure triggers immediatelytrue: Exposure triggers when element enters viewport- Cross-variant tracking: Tracks elements from ALL variants for unbiased exposure
Core API Methods
// Apply changes from ABsmartly context
await plugin.applyChanges('experiment-name');
// Apply individual change
const success = plugin.applyChange(change, 'experiment-name');
// Remove changes
plugin.removeChanges('experiment-name');
// Get applied changes
const changes = plugin.getAppliedChanges('experiment-name');
// Clean up resources
plugin.destroy();Documentation
For detailed documentation:
- Optimized Loading Guide - Best practices for loading the SDK and plugins with minimal performance impact
- Exposure Tracking Guide - Understanding trigger_on_view and preventing sample ratio mismatch
Anti-Flicker Support
Prevent content flash before experiments load with two modes:
Quick Start
Hide entire page with smooth fade-in:
const domPlugin = new DOMChangesPlugin({
context: context,
hideUntilReady: 'body',
hideTransition: '0.3s ease-in' // Smooth fade-in
});Hide only experiment elements (recommended):
<!-- Mark elements that need anti-flicker -->
<div data-absmartly-hide>
Hero section with experiment
</div>
<div data-absmartly-hide>
CTA button being tested
</div>const domPlugin = new DOMChangesPlugin({
context: context,
hideUntilReady: '[data-absmartly-hide]', // CSS selector for elements to hide
hideTransition: '0.4s ease-out'
});Hide multiple types of elements:
const domPlugin = new DOMChangesPlugin({
context: context,
hideUntilReady: '[data-absmartly-hide], [data-custom-hide], .test-element',
hideTransition: '0.3s ease-in'
});How It Works
During Load (Before Experiments Applied):
/* No transition: */ body { visibility: hidden !important; } /* With transition: */ body { visibility: hidden !important; opacity: 0 !important; }After Experiments Applied:
- No transition: Instant reveal (removes
visibility:hidden) - With transition: Smooth 4-step fade-in:
- Remove
visibility:hidden - Add CSS transition
- Animate opacity 0 → 1
- Clean up after transition completes
- Remove
- No transition: Instant reveal (removes
Configuration Options
new DOMChangesPlugin({
// Anti-flicker configuration
hideUntilReady: 'body', // CSS selector for elements to hide
// Examples:
// 'body' - hide entire page
// '[data-absmartly-hide]' - hide only marked elements
// '[data-absmartly-hide], .test' - hide multiple selectors
// false: disabled (default)
hideTimeout: 3000, // Max wait time in ms (default: 3000)
// Content auto-shows after timeout
// even if experiments fail to load
hideTransition: '0.3s ease-in', // CSS transition for fade-in
// Examples: '0.3s ease-in', '0.5s linear'
// false: instant reveal (default)
})Why This Matters
Without anti-flicker:
User sees: Original → FLASH → Experiment Version
^^^^^^^^ ^^^^^ ^^^^^^^^^^^^^^^^^^
(bad UX) (jarring)With anti-flicker:
User sees: [Hidden] → Experiment Version (smooth fade-in)
^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
(no flash) (professional)Best Practices
✅ Use hideUntilReady: '[data-absmartly-hide]' (Recommended)
- Mark only elements being tested
- Faster perceived load time
- Better user experience
- No CLS (Cumulative Layout Shift)
<nav>Normal navigation</nav>
<div data-absmartly-hide>
<!-- Only this hero is hidden -->
<h1>Hero headline being tested</h1>
</div>
<main>Rest of content visible immediately</main>const domPlugin = new DOMChangesPlugin({
context: context,
hideUntilReady: '[data-absmartly-hide]',
hideTransition: '0.3s ease-in'
});✅ Use hideUntilReady: 'body' (For whole-page experiments)
- Complete redesigns
- Multiple changes across page
- Ensures zero flicker
✅ Set appropriate timeout
{
hideTimeout: 2000 // Faster for good connections
hideTimeout: 3000 // Balanced (default)
hideTimeout: 5000 // Safer for slow connections
}⚠️ Avoid long transitions
hideTransition: '0.2s ease-in' // ✅ Subtle, professional
hideTransition: '0.3s ease-out' // ✅ Smooth
hideTransition: '1s linear' // ❌ Too slow, feels brokenIndustry Standard Comparison
| Tool | Method | Our Implementation |
|------|--------|-------------------|
| Google Optimize | opacity: 0 | ✅ visibility:hidden (better) |
| Optimizely | opacity: 0 | ✅ visibility:hidden (better) |
| VWO | opacity: 0 on body | ✅ Both modes supported |
| ABsmartly | - | ✅ visibility:hidden + optional smooth fade |
Why visibility:hidden is better:
- ✅ Hides from screen readers
- ✅ Prevents interaction
- ✅ No CLS (Cumulative Layout Shift)
- ✅ Can add smooth opacity transition
Style Persistence
Automatically reapply experiment styles when frameworks (React/Vue/Angular) overwrite them:
The Problem
// Experiment changes button to red
{ selector: '.button', type: 'style', value: { backgroundColor: 'red' } }
// User hovers → React's hover handler changes it back to blue
// Experiment style is lost! ❌The Solution
{
selector: '.button',
type: 'style',
value: { backgroundColor: 'red' },
persistStyle: true // ✅ Reapply when React overwrites it
}How It Works
- MutationObserver watches for style attribute changes
- Detects when framework overwrites experiment styles
- Automatically reapplies experiment styles
- Throttled logging (max once per 5 seconds to avoid spam)
When It's Enabled
Automatically in SPA mode:
new DOMChangesPlugin({
context: context,
spa: true // ✅ Style persistence auto-enabled for all style changes
})Opt-in per change:
{
selector: '.button',
type: 'style',
value: { backgroundColor: 'red' },
persistStyle: true // ✅ Explicit opt-in (works even if spa: false)
}Use Cases
✅ React components with hover states:
// Button has React onClick that changes style
{
selector: '.cta-button',
type: 'style',
value: { backgroundColor: '#ff6b6b' },
persistStyle: true // Survives React re-renders
}✅ Vue reactive styles:
{
selector: '.dynamic-price',
type: 'style',
value: { color: 'green', fontWeight: 'bold' },
persistStyle: true // Persists through Vue updates
}✅ Angular material components:
{
selector: 'mat-button',
type: 'style',
value: { backgroundColor: '#1976d2' },
persistStyle: true // Works with Angular's style binding
}❌ Static HTML (persistence not needed):
{
selector: '.static-banner',
type: 'style',
value: { display: 'none' },
persistStyle: false // Not needed, saves performance
}URL Filtering
Target experiments to specific pages or URL patterns using the urlFilter property. This enables precise control over where experiments run without code changes.
Basic URL Filtering
Wrap your DOM changes in a configuration object with urlFilter:
{
urlFilter: '/products', // Simple path match
changes: [
{
selector: '.product-title',
type: 'style',
value: { color: 'red' }
}
]
}URL Filter Configuration
{
urlFilter: {
include: ['/checkout', '/cart'], // Run on these URLs
exclude: ['/checkout/success'], // But NOT on these
mode: 'simple', // 'simple' or 'regex'
matchType: 'path' // 'full-url', 'path', 'domain', 'query', 'hash'
},
changes: [ /* ... */ ]
}Match Types
path (default) - Match against pathname + hash:
// URL: https://example.com/products/shoes#new
// Matches: "/products/shoes#new"
{
urlFilter: { include: ['/products/*'], matchType: 'path' },
changes: [ /* ... */ ]
}full-url - Match complete URL including protocol and domain:
// URL: https://example.com/products
// Matches: "https://example.com/products"
{
urlFilter: { include: ['https://example.com/products'], matchType: 'full-url' },
changes: [ /* ... */ ]
}domain - Match only the hostname:
// URL: https://shop.example.com/products
// Matches: "shop.example.com"
{
urlFilter: { include: ['shop.example.com'], matchType: 'domain' },
changes: [ /* ... */ ]
}query - Match only query parameters:
// URL: https://example.com/products?category=shoes
// Matches: "?category=shoes"
{
urlFilter: { include: ['*category=shoes*'], matchType: 'query' },
changes: [ /* ... */ ]
}hash - Match only hash fragment:
// URL: https://example.com/page#section-1
// Matches: "#section-1"
{
urlFilter: { include: ['#section-*'], matchType: 'hash' },
changes: [ /* ... */ ]
}Pattern Matching Modes
Simple Mode (default) - Glob-style wildcards:
{
urlFilter: {
include: [
'/products/*', // All product pages
'/checkout*', // Checkout and checkout/*
'/about', // Exact match
'*/special-offer' // Any path ending with /special-offer
],
mode: 'simple'
},
changes: [ /* ... */ ]
}Simple Mode Wildcards:
*= Match any characters (0 or more)?= Match single character- No wildcards = Exact match
Regex Mode - Full regex power:
{
urlFilter: {
include: [
'^/products/(shoes|bags)', // Products in specific categories
'/checkout/(step-[1-3])', // Checkout steps 1-3 only
'\\?utm_source=email' // URLs with email traffic
],
mode: 'regex'
},
changes: [ /* ... */ ]
}Include and Exclude Logic
Include only:
{
urlFilter: { include: ['/products/*'] },
changes: [ /* ... */ ]
}
// ✅ Runs on: /products/shoes, /products/bags
// ❌ Skips: /about, /checkoutInclude with exclusions:
{
urlFilter: {
include: ['/products/*'],
exclude: ['/products/admin', '/products/*/edit']
},
changes: [ /* ... */ ]
}
// ✅ Runs on: /products/shoes, /products/bags
// ❌ Skips: /products/admin, /products/shoes/editExclude only (match all except):
{
urlFilter: { exclude: ['/admin/*', '/api/*'] },
changes: [ /* ... */ ]
}
// ✅ Runs on: /products, /checkout, /about
// ❌ Skips: /admin/users, /api/dataNo filter (match all):
{
urlFilter: {}, // or omit urlFilter entirely
changes: [ /* ... */ ]
}
// ✅ Runs on all pagesMatch nothing (disable experiment):
{
urlFilter: { include: [] }, // Empty array = explicit "match nothing"
changes: [ /* ... */ ]
}
// ❌ Never runs (useful for temporary disabling)SRM Prevention with URL Filters
Critical: When using urlFilter, the plugin implements Sample Ratio Mismatch (SRM) prevention by tracking ALL variants, not just the current variant:
// Variant 0 (Control): No URL filter
{
changes: [
{ selector: '.hero', type: 'text', value: 'Welcome' }
]
}
// Variant 1: Only runs on /products
{
urlFilter: '/products',
changes: [
{ selector: '.hero', type: 'text', value: 'Shop Now' }
]
}Without SRM prevention:
- Users on
/products→ Tracked in both variants ✅ - Users on
/about→ Only tracked in variant 0 ❌ - Result: Biased sample sizes!
With SRM prevention (automatic):
- Users on
/products→ Tracked in both variants ✅ - Users on
/about→ Tracked in both variants ✅ - Result: Unbiased sample sizes! ✅
The plugin checks if ANY variant matches the current URL. If ANY variant matches, ALL variants are tracked for that user, ensuring fair sample distribution.
URL Change Detection (SPA Mode)
In SPA mode (spa: true), the plugin automatically detects URL changes and re-evaluates experiments:
new DOMChangesPlugin({
context: context,
spa: true, // Enable URL change detection
autoApply: true
});What it tracks:
- ✅
history.pushState()- Client-side navigation - ✅
history.replaceState()- URL updates - ✅
popstateevents - Browser back/forward buttons
What happens on URL change:
- Remove all currently applied DOM changes
- Re-evaluate URL filters with the new URL
- Apply changes for experiments matching the new URL
- Re-track exposure for newly applied experiments
Example flow:
User lands on: /products → Apply product page experiments
User navigates to: /checkout → Remove product experiments, apply checkout experiments
User goes back: /products → Remove checkout experiments, re-apply product experimentsWithout SPA mode:
- URL changes are NOT detected
- Experiments applied on initial page load remain active
- New experiments on the new URL are NOT applied
- Use
spa: falseonly for static sites without client-side routing
Complete Examples
E-commerce experiment on specific pages:
{
urlFilter: {
include: ['/products/*', '/collections/*'],
exclude: ['/products/admin', '*/edit'],
matchType: 'path',
mode: 'simple'
},
changes: [
{
selector: '.add-to-cart-button',
type: 'style',
value: { backgroundColor: '#ff6b6b', color: 'white' },
trigger_on_view: true
}
]
}Blog post experiment with regex:
{
urlFilter: {
include: ['^/blog/20(24|25)'], // Only 2024-2025 blog posts
mode: 'regex',
matchType: 'path'
},
changes: [
{
selector: '.blog-cta',
type: 'text',
value: 'Subscribe to our newsletter!'
}
]
}Landing page experiment with UTM parameters:
{
urlFilter: {
include: ['*utm_campaign=summer*'],
matchType: 'query'
},
changes: [
{
selector: '.hero-banner',
type: 'html',
value: '<h1>Summer Sale - 50% Off!</h1>'
}
]
}Homepage only (exact match):
{
urlFilter: '/', // Shorthand for { include: ['/'] }
changes: [
{
selector: '.hero-title',
type: 'text',
value: 'Limited Time Offer!'
}
]
}Multiple pages (array shorthand):
{
urlFilter: ['/pricing', '/features'], // Shorthand for { include: [...] }
changes: [ /* ... */ ]
}SPA Mode (React/Vue/Angular Support)
Enable spa: true for Single Page Applications. This automatically activates three critical features:
1. URL Change Detection
Automatically detects client-side navigation and re-evaluates experiments:
- Intercepts
history.pushState()andhistory.replaceState() - Listens to
popstateevents (browser back/forward) - Re-applies appropriate experiments when URL changes
See URL Change Detection above for details.
2. Wait for Element (Lazy-Loaded Content)
Automatically waits for elements that don't exist yet in the DOM:
{
selector: '.lazy-loaded-button',
type: 'style',
value: { backgroundColor: 'red' }
// No waitForElement needed - SPA mode handles it automatically!
}Without SPA mode: Change skipped if element doesn't exist With SPA mode: Observes DOM and applies when element appears
3. Style Persistence (Framework Conflicts)
Automatically re-applies styles when frameworks overwrite them:
{
selector: '.button',
type: 'style',
value: { backgroundColor: 'red' }
// No persistStyle needed - SPA mode handles it automatically!
}Without SPA mode: React hover states can overwrite experiment styles With SPA mode: Detects and re-applies styles automatically
When to Use SPA Mode
✅ Enable spa: true for:
- React applications
- Vue.js applications
- Angular applications
- Any app with client-side routing
- Apps with lazy-loaded components
- Apps with dynamic DOM updates
❌ Keep spa: false for:
- Static HTML sites
- Server-rendered pages without client-side routing
- Sites where performance is critical and you don't need dynamic features
Manual Override
You can still use flags explicitly if you need granular control:
{
selector: '.button',
type: 'style',
value: { backgroundColor: 'red' },
persistStyle: true, // Force enable (even if spa: false)
waitForElement: true // Force enable (even if spa: false)
}Configuration Options
new DOMChangesPlugin({
// Required
context: absmartlyContext, // ABsmartly context
// Core options
autoApply: true, // Auto-apply changes from SDK
spa: true, // Enable SPA support
// ✅ Auto-enables: waitForElement + persistStyle
visibilityTracking: true, // Track viewport visibility
variableName: '__dom_changes', // Variable name for DOM changes
debug: false, // Enable debug logging
// Anti-flicker options
hideUntilReady: false, // CSS selector (e.g., 'body', '[data-absmartly-hide]') or false
hideTimeout: 3000, // Max wait time (ms)
hideTransition: false, // CSS transition (e.g., '0.3s ease-in') or false
})Browser Compatibility
- Modern browsers (Chrome, Firefox, Safari, Edge)
- Internet Explorer 11+ (with polyfills)
- Mobile browsers (iOS Safari, Chrome Mobile)
License
MIT
