I built 6 JavaScript widgets with zero dependencies — here's what I learned from each
Source: Dev.to
1️⃣ WhatsApp Chat Button
Widget: A floating button that opens a WhatsApp chat with a pre‑filled message.
Optional popup card with agent name, avatar, and online indicator.
What I Learned: The Pulse‑Animation Fight
My first version used setInterval to toggle a CSS class for the pulse ring.
That caused layout thrashing – the animation stuttered on low‑end phones because JavaScript was fighting the browser’s rendering pipeline.
Fix: Move everything into a pure CSS @keyframes animation and only use JS to add/remove the class once.
// Bad – JS fighting the renderer
setInterval(() => {
pulse.classList.toggle('active');
}, 1000);
// Good – CSS handles the animation entirely
// JS only adds the class once at init
pulse.classList.add('pulse-active');
@keyframes fc-pulse {
0% { transform: scale(1); opacity: 0.7; }
100% { transform: scale(1.8); opacity: 0; }
}
.pulse-active {
animation: fc-pulse 2.2s ease-out infinite;
}
WhatsApp URL Trick
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
encodeURIComponent is non‑negotiable – without it any message containing &, emojis, etc., silently breaks the URL.
2️⃣ “Sarah from London just purchased” Pop‑ups
Widget: Cycles through a list of notifications with configurable timing.
What I Learned: Pausing a Running CSS Transition Is a Rabbit Hole
The widget pauses the countdown progress bar on hover and resumes where it left off.
When you pause a CSS transition mid‑way by removing it, the element snaps to its final value instantly.
Solution: Capture the computed width at the moment of pause, freeze it, then re‑apply the transition with the remaining time.
element.addEventListener('mouseenter', () => {
// Capture current rendered width BEFORE removing transition
const computed = window.getComputedStyle(progressBar).width;
progressBar.style.transition = 'none';
progressBar.style.width = computed; // freeze it here
// Record when we paused
this._pausedAt = Date.now();
});
element.addEventListener('mouseleave', () => {
const elapsed = Date.now() - this._pausedAt;
this._remaining -= elapsed;
// Resume with remaining time
progressBar.style.transition = `width ${this._remaining}ms linear`;
progressBar.style.width = '0%';
this._pausedAt = null;
});
Pattern:
- Capture computed style.
- Set
transition: none.- Set explicit value (freeze).
- Re‑add transition with the remaining duration.
3️⃣ Consent Modal (Accept All / Reject / Manage Preferences)
Widget: Modal with toggle switches per category, stored consent.
What I Learned: Store Consent in Two Places
- Initially stored consent only in
localStorage. - Problems:
- Server‑Side Rendering (SSR) frameworks can’t read
localStorageon the server. - Privacy‑focused browsers clear
localStorageaggressively.
- Server‑Side Rendering (SSR) frameworks can’t read
Solution: Store in both localStorage and a cookie. Read from localStorage first (faster), fall back to the cookie.
function saveConsent(key, data, days) {
// Cookie (accessible server‑side, survives localStorage clears)
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${key}=${encodeURIComponent(JSON.stringify(data))};expires=${expires};path=/;SameSite=Lax`;
// localStorage (faster reads client‑side)
localStorage.setItem(key, JSON.stringify(data));
}
function loadConsent(key) {
// Try localStorage first
const ls = localStorage.getItem(key);
if (ls) return JSON.parse(ls);
// Fall back to cookie
const match = document.cookie.match(
new RegExp('(?:^|; )' + key + '=([^;]*)')
);
if (match) return JSON.parse(decodeURIComponent(match[1]));
return null; // no consent stored
}
Important: SameSite=Lax on the cookie is crucial for GDPR compliance; without it some browsers block the cookie in cross‑origin contexts.
4️⃣ Sticky Top/Bottom Bar
Widget: Fixed bar for promotions, sale countdowns, shipping notices; rotates multiple messages.
What I Learned: Offsetting Body Padding for a Fixed Bar
Adding padding-top (or padding-bottom) to “ when a fixed bar appears sounds trivial—it isn’t. Three things break it:
- Sticky navs – a sticky header now has the wrong
topvalue. - Scroll restoration – on back navigation the browser restores the scroll position before the bar renders, causing a jump.
- Resize events – the bar’s height can change (e.g., text wraps on mobile), so the padding must update.
Solution: Make offsetBody opt‑in and document the edge cases rather than trying to solve every layout scenario.
if (cfg.position === 'top' && cfg.sticky && cfg.offsetBody) {
document.body.style.paddingTop = cfg.height + 'px';
}
// Clean up on dismiss
dismiss() {
if (cfg.position === 'top' && cfg.offsetBody) {
document.body.style.paddingTop = '';
}
}
Countdown Timer Helper
const diff = new Date(targetDate) - Date.now();
const h = Math.floor(diff / 36e5);
const m = Math.floor((diff % 36e5) / 6e4);
const s = Math.floor((diff % 6e4) / 1e3);
const pad = n => String(n).padStart(2, '0');
// → "02:44:17"
5️⃣ ToastKit
Widget: ToastKit.success("Saved!") – six types, six positions, light/dark/auto themes, promise API.
What I Learned: The Promise Pattern Is the Whole Point
Before adding the promise helper, the toast system felt like just another toast system. After adding it, the whole thing clicked.
ToastKit.promise = function(promise, messages) {
const t = ToastKit.loading(messages.loading, { duration: 0 });
promise
.then(() => t.update(messages.success, 'success'))
.catch(() => t.update(messages.error, 'error'));
return promise; // passthrough so you can still await it
};
Now you can do:
ToastKit.promise(
fetch('/api/save'),
{
loading: 'Saving…',
success: 'Saved!',
error: 'Failed to save.'
}
);
The toast automatically reflects the async state without any extra boilerplate.
TL;DR
| Widget | Key Takeaway |
|---|---|
| WhatsApp button | Use pure CSS @keyframes for pulse; JS only adds class once. |
| Purchase pop‑ups | Capture computed style, freeze with transition:none, then resume. |
| Consent modal | Store consent in both localStorage and a cookie (SameSite=Lax). |
| Sticky bar | offsetBody should be opt‑in; handle sticky navs, scroll restoration, and resize. |
| ToastKit | Wrap promises with ToastKit.promise for automatic loading/success/error toasts. |
These patterns let you ship tiny, dependency‑free UI widgets that behave well across devices, browsers, and rendering environments. Happy building!
ToastKit – Promise‑based Toasts
// Usage
ToastKit.promise(
fetch('/api/save', { method: 'POST' }),
{
loading: 'Saving...',
success: 'Saved!',
error: 'Failed. Try again.',
}
);
The key insight: returning the original promise lets the caller still chain .then() (or await) on it. The toast is just a side‑effect, not a gate.
Auto Dark Mode
let theme = opts.theme;
if (theme === 'auto') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
Two lines. Could have spent hours on this, shouldn’t have.
Reading‑Progress Widget
A thin bar at the top/bottom that shows reading progress. It can track the whole page or a specific element (e.g., an article).
Full‑page progress (easy)
const docH = document.documentElement.scrollHeight - window.innerHeight;
const percent = (window.scrollY / docH) * 100;
Element‑only progress (hard)
function getElementProgress(selector) {
const el = document.querySelector(selector);
const rect = el.getBoundingClientRect();
// rect.top is relative to the viewport → convert to absolute position
const elTop = rect.top + window.scrollY;
const elHeight = el.offsetHeight;
const winH = window.innerHeight;
// How much of the element have we scrolled through?
const scrolled = window.scrollY + winH - elTop;
return Math.min(100, Math.max(0, (scrolled / elHeight) * 100));
}
window.scrollY + winHconverts “how far the viewport has scrolled” to “how far the bottom of the viewport has traveled” – the actual measure of what the user has seen.
Scroll‑to‑Top Button (smooth show/hide)
No JavaScript toggling of display; just toggle a class and let CSS animate.
#scroll-btn {
opacity: 0;
transform: translateY(12px) scale(0.9);
pointer-events: none;
transition: opacity 0.28s ease,
transform 0.28s cubic-bezier(0.34,1.2,0.64,1);
}
#scroll-btn.visible {
opacity: 1;
transform: none;
pointer-events: all;
}
Toggle the visible class from JS; CSS handles the animation. Same principle as the pulse‑ring effect.
Reflections
- Repetition – Building these widgets felt repetitive at first; they’re all small, self‑contained, and share a similar structure.
- Pattern – CSS should animate, JS should manage state.
- Animating in JavaScript makes the browser fight you.
- Moving the animation to CSS and using JS only to add/remove classes or set CSS variables yields smooth results.
- Edge Cases – The difference between a professional‑feeling widget and a half‑baked one lies almost entirely in edge‑case handling:
- Hovering during animation
- Missing elements
- Mobile behavior
That’s where most of the time goes.
Available Widgets
All 6 widgets are sold on Gumroad:
- Link:
rajabdev.gumroad.com - Price: $9 each
- Tech: Vanilla JS, single script tag
- Demo: The best way to see them is to run the demos.
Feel free to ask any implementation‑detail questions in the comments!