Skip to content
← Back to snippets
JavaScript

JavaScript in the browser: DOM, events, async

Ten snippets for browser-side work: debounce and throttle, detecting editable event targets, smooth anchor scrolling, lazy-loading autoplaying video, merging Tailwind classes, contrast decisions, and reading a JWT payload.

Browser code fails in ways server code does not: the user scrolls during your animation, types faster than your handler, opens the page with an anchor already in the URL, or has autoplay blocked. These ten snippets each handle one of those situations.

Debounce and throttle are not interchangeable

Both limit how often a function runs, and they are routinely swapped by mistake. Debounce waits for a pause — it is what you want for a search box, because you care about the final value, not the intermediate ones. Throttle runs at a fixed maximum rate — it is what you want for a scroll or resize handler, because you need periodic updates and never getting one is worse than getting a stale one. Using debounce on scroll produces a UI that updates only after the user stops, which usually reads as broken.

Keyboard shortcuts and the editable-target problem

Any app with single-key shortcuts eventually fires one while the user is typing in a form. Checking event.target.tagName === 'INPUT' is the usual first attempt and misses textareas, contenteditable regions, and elements inside a shadow root. The check needs to cover all three, and it is the difference between a shortcut system that feels considered and one that eats characters.

Autoplay is a permission, not a setting

Browsers block autoplaying video with sound, and muted autoplay still costs bandwidth and decoding on every video in the document. Attaching playback to an IntersectionObserver means a video starts only when it is actually on screen and stops when it leaves — which is both the polite behaviour and the fast one. The MDN reference on autoplay documents which combinations current browsers permit.

Reading a JWT is not verifying one

The payload snippet decodes base64url and returns the claims. That is all it does, and the distinction matters: anyone can craft a token whose payload says whatever they want. Decoding client-side is fine for deciding what to render; it is never a basis for deciding what to allow. Verification requires the signing key and belongs on the server.

Colour and class-merge helpers

Deciding whether to put white or black text on an arbitrary background is a perceived-luminance calculation, not a lightness one — the naive (r+g+b)/3 version gets green badly wrong. And merging Tailwind classes is not string concatenation: px-2 px-4 leaves both in the class list and lets specificity order decide, which is why a dedicated merge helper exists.

JavaScriptJavaScript

Merge Tailwind classes safely (cn helper)

Combines conditional classes with clsx and resolves conflicting Tailwind utilities with tailwind-merge — the standard "cn()" helper used across shadcn/ui-style codebases.

How it works: clsx collapses an array of strings/objects/falsy values into one class string; twMerge then resolves Tailwind-specific conflicts (e.g. px-2 vs px-4) by keeping only the last one that wins, instead of leaving both in the DOM. Requires npm install clsx tailwind-merge.

import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs) {
  return twMerge(clsx(inputs))
}

// Example usage — later classes win over earlier conflicting ones
cn('px-2 py-1', condition && 'px-4', 'text-sm')
// condition = true  -> "py-1 px-4 text-sm"
// condition = false -> "px-2 py-1 text-sm"
JavaScriptJavaScript

Debounce a function

Delay a function's execution until a burst of calls has stopped — ideal for search inputs and resize handlers.

How it works:

  1. Each call to the debounced function clears the previous pending timer.
  2. A new timer is scheduled for delay ms.
  3. The wrapped function only actually runs once calls stop arriving for delay ms.
function debounce(fn, delay = 300) {
  let timeoutId

  return function debounced(...args) {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => fn.apply(this, args), delay)
  }
}

// Example usage
const onResize = debounce(() => console.log('window resized'), 250)
window.addEventListener('resize', onResize)
JavaScriptJavaScript

Throttle a function

Ensures a function runs at most once every X milliseconds — ideal for scroll and mousemove handlers that would otherwise fire hundreds of times a second.

How it works: unlike debounce (which waits for calls to stop before running), throttle guarantees the function still runs periodically DURING a continuous stream of calls — the first call fires immediately, then further calls are ignored until limit ms have passed.

function throttle(fn, limit = 200) {
  let waiting = false

  return function throttled(...args) {
    if (waiting) return
    fn.apply(this, args)
    waiting = true
    setTimeout(() => { waiting = false }, limit)
  }
}

// Example usage
const onScroll = throttle(() => console.log('scroll position:', window.scrollY), 200)
window.addEventListener('scroll', onScroll)
JavaScriptJavaScript

Check if a hex color is light or dark

Computes perceived brightness from a hex color to decide whether black or white text reads better on top of it.

How it works: it converts the hex color to RGB and applies the standard luminance-weighted brightness formula (299r + 587g + 114b) — values above 155 are treated as "light" backgrounds.

function isColorLight(hex) {
  hex = hex.replace(/^#/, '')
  if (hex.length === 3) {
    hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
  }
  const r = parseInt(hex.substring(0, 2), 16)
  const g = parseInt(hex.substring(2, 4), 16)
  const b = parseInt(hex.substring(4, 6), 16)
  const brightness = (r * 299 + g * 587 + b * 114) / 1000
  return brightness > 155
}

// Example usage — pick readable text color for any background
const textColor = isColorLight('#F5D90A') ? 'black' : 'white'
JavaScriptJavaScript

Check if an event target is an editable field

Guards global keyboard shortcuts from firing while the user is typing in an input, textarea, select, or contenteditable element.

How it works: it checks the element's isContentEditable flag and its tag name against the usual text-entry elements, so you can skip handling a shortcut key while the user is actively typing.

function isEditable(target) {
  if (!target) return false
  const tag = (target.tagName || '').toLowerCase()
  return target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select'
}

// Example usage — only trigger a shortcut when not typing
window.addEventListener('keydown', (e) => {
  if (e.key === '/' && !isEditable(document.activeElement)) {
    e.preventDefault()
    // focus a search input, open a command palette, etc.
  }
})
JavaScriptJavaScript

Lazy-Load Autoplaying Videos with IntersectionObserver

Delay video loading until it enters the viewport, then start muted playback once — keeping below-the-fold media out of the initial page load.

How it works: preload="none" asks the browser not to fetch the video eagerly, while omitting autoplay lets the observer control the first playback. Keeping muted and playsinline is important for autoplay policies and inline playback on mobile.

<video
  class="lazy"
  width="720"
  height="404"
  loop
  muted
  playsinline
  preload="none"
  poster="/images/ocean.png"
  src="/videos/demo/ocean.mp4"
></video>
const observer = new IntersectionObserver((entries, observer) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return

    const video = entry.target
    video.play().catch(() => {
      // Autoplay can still be blocked by browser policy.
    })
    observer.unobserve(video)
  })
})

document.querySelectorAll('.lazy').forEach((video) => {
  observer.observe(video)
})
JavaScriptJavaScript

Smooth Scroll to Anchor

Scrolls the page to an element by ID, accounting for an offset (e.g. a fixed header height) — no jQuery, native smooth scroll.

How it works: getBoundingClientRect().top returns the element's position relative to the viewport. Adding window.scrollY converts that to an absolute position, then we subtract the offset for the header. scrollTo({ behavior: 'smooth' }) animates natively.

function scrollToAnchor(id, offsetPx = 0) {
  const el = document.getElementById(id)
  if (!el) return

  const top = el.getBoundingClientRect().top + window.scrollY - offsetPx
  window.scrollTo({ top, behavior: 'smooth' })
}

// Version with prefers-reduced-motion support
function scrollToAnchorAccessible(id, offsetPx = 0) {
  const el = document.getElementById(id)
  if (!el) return

  const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
  const top = el.getBoundingClientRect().top + window.scrollY - offsetPx

  window.scrollTo({ top, behavior: prefersReduced ? 'instant' : 'smooth' })
}

// Example usage
// Navigation links with a 64px fixed header
document.querySelectorAll('a[href^="#"]').forEach((link) => {
  link.addEventListener('click', (e) => {
    e.preventDefault()
    const id = link.getAttribute('href').slice(1)
    scrollToAnchorAccessible(id, 64)
  })
})
JavaScriptJavaScript

sleep() / Async Delay

A minimal sleep function for JavaScript and TypeScript — pauses an async function for a given number of milliseconds without any third-party library.

How it works: returns a Promise that resolves after setTimeout. Awaiting it pauses only the current async function without blocking the event loop.

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

// Example usage — delay between retry attempts
async function pollUntilReady(checkFn, intervalMs = 500, maxAttempts = 10) {
  for (let i = 0; i < maxAttempts; i++) {
    if (await checkFn()) return true
    await sleep(intervalMs)
  }
  return false
}

// Client-side rate limiting between batch requests
for (const batch of batches) {
  await processBatch(batch)
  await sleep(200)
}
JavaScriptJavaScript

Get IP geolocation info (ipapi.co)

Look up a visitor's country and city from their IP address using the free ipapi.co API.

How it works: a GET request to ipapi.co/{ip}/json/ returns the geolocation data as JSON — including country_name and city. Leaving ip empty makes the API auto-detect the caller's own IP, so it works great for greeting the current visitor.

async function getIpInfo(ip = '') {
  const response = await fetch(`https://ipapi.co/${ip}/json/`)
  return response.json()
}

// Example usage
const info = await getIpInfo() // empty ip -> detects the visitor's own IP
console.log(`${info.city}, ${info.country_name}`)

const other = await getIpInfo('8.8.8.8') // or look up a specific IP
console.log(other.country_name)
JavaScriptJavaScript

Decode JWT Payload Without Verification

Extracts the payload from a JWT token on the client without verifying the signature — handy for reading exp, userId, and roles in UI. Never use for server-side authorization.

How it works: a JWT has three dot-separated parts. The second part is a Base64URL-encoded JSON payload. This function decodes it and parses the JSON. The signature is not verified — this is for client-side data reading only.

function decodeJwtPayload(token) {
  try {
    const base64 = token.split('.')[1]
    if (!base64) return null
    // Base64URL → Base64
    const normalized = base64.replace(/-/g, '+').replace(/_/g, '/')
    return JSON.parse(atob(normalized))
  } catch {
    return null
  }
}

function isTokenExpired(token) {
  const payload = decodeJwtPayload(token)
  if (!payload?.exp) return true
  return Date.now() >= payload.exp * 1000
}

// Example usage
const payload = decodeJwtPayload(localStorage.getItem('token'))
// { sub: '42', role: 'admin', exp: 1720000000 }

if (isTokenExpired(token)) {
  // refresh or log out
}
// CONNECT

Contact Information

Submit a request or write to me directly on Telegram. I'll reply within 24 hours.

Provide either your email or Telegram so I can reply.