I've been on a mission to build the smallest possible versions of the UI widgets every website needs — things like a WhatsApp chat button, a cookie banner, a toast notification system. The constraint: zero dependencies, one script tag, works anywhere.
Six widgets later, here's what actually surprised me.
1. WhatsApp Floating Button
The 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 a setInterval to toggle a CSS class for the pulse ring. It caused layout thrashing — the animation would stutter on low-end phones because JavaScript was fighting the browser's rendering pipeline.
The fix was to move everything into a pure CSS @keyframes animation and only use JS to add/remove the class once. Obvious in hindsight, painful to debug.
// 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;
}
The trick for the WhatsApp URL:
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
That encodeURIComponent is non-negotiable. Without it, any message with an ampersand or emoji breaks the URL silently.
2. Social Proof Toast Notifications
The widget: Those "Sarah from London just purchased" popups. 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. Sounds simple. It is not simple.
When you pause a CSS transition mid-way by removing it, the element snaps to its final value instantly. You have to capture the computed width at the moment of pause:
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;
});
This pattern — capture computed style, set transition to none, set explicit value, then re-add transition — comes up constantly in UI animation work.
3. GDPR Cookie Consent Banner
The widget: Accept All / Reject / Manage Preferences with a modal, toggle switches per category, stored consent.
What I learned: Store consent in TWO places
I originally stored consent only in localStorage. Then someone pointed out that server-side rendering frameworks can't read localStorage on the server, and some privacy-focused browsers clear it aggressively.
The 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
}
Also learned: the SameSite=Lax attribute on the cookie is important for GDPR compliance. Without it, some browsers will block the cookie in cross-origin contexts.
4. Announcement Bar
The widget: Sticky top/bottom bar for promotions, sale countdowns, shipping notices. Rotates multiple messages.
What I learned: Offsetting body padding for a fixed bar
Adding padding-top to the body when a fixed bar appears at the top sounds trivial. It isn't. Three things break it:
-
Sticky navs — if you also have a sticky header, it now has the wrong
topvalue - Scroll restoration — when the user navigates back, the browser restores scroll position before your bar renders, creating a jump
- Resize events — the padding needs to update if the bar height changes (e.g. text wraps on mobile)
My solution was to make offsetBody opt-in and document the edge cases clearly rather than trying to solve for every layout:
if (cfg.position === 'top' && cfg.sticky && cfg.offsetBody) {
document.body.style.paddingTop = cfg.height + 'px';
}
// And clean up on dismiss
dismiss() {
if (cfg.position === 'top' && cfg.offsetBody) {
document.body.style.paddingTop = '';
}
}
The countdown timer was more fun. Converting a millisecond difference to hⓂ️s with zero-padding:
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. Toast Notification System
The 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 I built 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 chain
};
// Usage
ToastKit.promise(
fetch('/api/save', { method: 'POST' }),
{
loading: 'Saving...',
success: 'Saved!',
error: 'Failed. Try again.',
}
);
The key insight: returning the original promise means the caller can still .then() on it. The toast is 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.
6. Reading Progress Bar
The widget: Thin bar at the top/bottom showing reading progress. Tracks full page or a specific element. Includes scroll-to-top button.
What I learned: Tracking an element (not the full page) is genuinely hard
Full-page progress is easy:
const docH = document.documentElement.scrollHeight - window.innerHeight;
const percent = (window.scrollY / docH) * 100;
Tracking only the article element is harder. You want progress to go 0→100% as the user scrolls from the top of the article to the bottom. The element's position changes as the user scrolls, so you can't just cache it:
function getElementProgress(selector) {
const el = document.querySelector(selector);
const rect = el.getBoundingClientRect();
// rect.top is relative to viewport, so we need 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));
}
The window.scrollY + winH converts from "how far has the viewport scrolled" to "how far has the bottom of the viewport traveled" — which is what actually determines how much content the user has seen.
The scroll-to-top button trick:
Appearing/disappearing smoothly without JavaScript toggling display:
#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 a class, let CSS handle the animation. Same principle as the pulse ring.
What surprised me most
Building these widgets felt repetitive at first — they're all small, self-contained, similar structure. But each one had at least one moment where I hit a wall I didn't expect.
The pattern that kept showing up: CSS should animate, JS should manage state. Every time I tried to animate something in JavaScript, it fought the browser. Every time I moved the animation to CSS and used JS only to add/remove classes or set CSS variables, it worked smoothly.
The other thing: edge cases are the product. The difference between a widget that feels professional and one that feels half-baked is almost entirely in the edge cases — what happens when you hover during the animation, what happens when the element isn't found, what happens on mobile. That's where the time goes.
All 6 are available on Gumroad at rajabdev.gumroad.com — $9 each, vanilla JS, one script tag. The demos are the best way to see them.
Happy to answer questions about any of the implementation details in the comments.
Top comments (0)