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

Published: (March 1, 2026 at 03:14 AM EST)
7 min read
Source: Dev.to

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:

  1. Capture computed style.
  2. Set transition: none.
  3. Set explicit value (freeze).
  4. Re‑add transition with the remaining duration.

Widget: Modal with toggle switches per category, stored consent.

  • Initially stored consent only in localStorage.
  • Problems:
    • Server‑Side Rendering (SSR) frameworks can’t read localStorage on the server.
    • Privacy‑focused browsers clear localStorage aggressively.

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:

  1. Sticky navs – a sticky header now has the wrong top value.
  2. Scroll restoration – on back navigation the browser restores the scroll position before the bar renders, causing a jump.
  3. 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

WidgetKey Takeaway
WhatsApp buttonUse pure CSS @keyframes for pulse; JS only adds class once.
Purchase pop‑upsCapture computed style, freeze with transition:none, then resume.
Consent modalStore consent in both localStorage and a cookie (SameSite=Lax).
Sticky baroffsetBody should be opt‑in; handle sticky navs, scroll restoration, and resize.
ToastKitWrap 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 + winH converts “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.
  • PatternCSS 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!

0 views
Back to Blog

Related posts

Read more »

JavaScript: The Beginning

JavaScript In 1995, a programmer named Brendan Eich was working at Netscape. At that time, websites were mostly static — they could display information, but the...

The 'skill-check' JS quiz

Question 1: Type coercion What does the following code output to the console? javascript console.log0 == '0'; console.log0 === '0'; Answer: true, then false Ex...