DEV Community

Cover image for I built 6 JavaScript widgets with zero dependencies — here's what I learned from each
Ali Rajab
Ali Rajab

Posted on

I built 6 JavaScript widgets with zero dependencies — here's what I learned from each

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');
Enter fullscreen mode Exit fullscreen mode
@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;
}
Enter fullscreen mode Exit fullscreen mode

The trick for the WhatsApp URL:

const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`;
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Sticky navs — if you also have a sticky header, it now has the wrong top value
  2. Scroll restoration — when the user navigates back, the browser restores scroll position before your bar renders, creating a jump
  3. 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 = '';
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.',
  }
);
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)