DEV Community

Mack
Mack

Posted on

How to Build a Zero-Dependency Web Tool with Vanilla JavaScript

There's a category of web app that doesn't need React. Or Vue. Or Svelte. Or a build step. Or node_modules.

I'm talking about single-purpose developer tools: JSON formatters, color pickers, regex testers, cron builders. Tools that take input, transform it, and show output. No auth, no database, no API calls.

For these, vanilla JavaScript isn't just "good enough" — it's better. Faster load times, zero maintenance burden, and deployment is literally copying files to a static host.

Here's the architecture pattern I use, demonstrated with a real tool: CronMaker, a visual cron expression generator.


The Architecture

Every tool follows this structure:

my-tool/
├── index.html    ← Structure + SEO
├── style.css     ← Theming + responsive layout
└── app.js        ← All logic in an IIFE
Enter fullscreen mode Exit fullscreen mode

Three files. No package.json. No webpack.config.js. No .babelrc. No tsconfig.json. Nothing to update, nothing to break.

Why an IIFE?

All JavaScript goes inside an Immediately Invoked Function Expression:

(() => {
  'use strict';

  // Your entire app lives here.
  // Nothing leaks to the global scope.
})();
Enter fullscreen mode Exit fullscreen mode

This gives you module-like encapsulation without module bundlers. No globals, no namespace collisions, no window.myApp pollution.


Step 1: State Management (Without a Library)

Even a simple tool has state. CronMaker tracks which fields are set to what values, whether you're in 5-field or 6-field mode, and the current theme.

Here's the pattern:

// === State ===
let mode = 5;           // 5-field or 6-field cron
let fieldStates = [];   // Each field's type and value

// === Constants ===
const FIELDS = [
  { name: 'Minute',       min: 0,  max: 59 },
  { name: 'Hour',         min: 0,  max: 23 },
  { name: 'Day of Month', min: 1,  max: 31 },
  { name: 'Month',        min: 1,  max: 12 },
  { name: 'Day of Week',  min: 0,  max: 6  },
];
Enter fullscreen mode Exit fullscreen mode

State is just variables. When state changes, you call updateAll() to re-render. That's it. No reactive proxies, no stores, no reducers.

function updateAll() {
  const expression = buildExpression();
  cronInput.value = expression;
  humanReadable.textContent = toHumanReadable(expression);
  renderExecutions(expression);
}
Enter fullscreen mode Exit fullscreen mode

When does this break down? When you have deeply nested state with many independent subscribers. For a single-purpose tool, that never happens.


Step 2: DOM — querySelector and Move On

const $ = s => document.querySelector(s);

const cronInput     = $('#cron-input');
const humanReadable = $('#human-readable');
const copyBtn       = $('#copy-btn');
const themeToggle   = $('#theme-toggle');
const presetsGrid   = $('#presets-grid');
const fieldGrid     = $('#field-grid');
const execList      = $('#executions-list');
Enter fullscreen mode Exit fullscreen mode

Cache your DOM references at init time. Use them everywhere. The $ alias saves typing and makes the code scannable.

For dynamic content (lists, grids), use innerHTML with template literals:

function renderPresets() {
  const presets = mode === 5 ? PRESETS_5 : PRESETS_6;
  presetsGrid.innerHTML = presets.map(p => `
    <button class="preset-btn" data-expr="${p.expr}">
      <code>${p.expr}</code>
      <span>${p.label}</span>
    </button>
  `).join('');

  // Event delegation on the container
  presetsGrid.onclick = e => {
    const btn = e.target.closest('.preset-btn');
    if (!btn) return;
    applyExpression(btn.dataset.expr);
  };
}
Enter fullscreen mode Exit fullscreen mode

Event Delegation

Instead of attaching listeners to every button, attach one to the parent. This pattern handles dynamic content naturally — no need to rebind after re-rendering.

container.onclick = e => {
  const btn = e.target.closest('[data-action]');
  if (!btn) return;

  switch (btn.dataset.action) {
    case 'copy':  copyToClipboard(); break;
    case 'reset': resetAll();        break;
    case 'share': shareURL();        break;
  }
};
Enter fullscreen mode Exit fullscreen mode

Step 3: The Core Logic (Parsing & Generation)

This is where your tool's actual value lives. For CronMaker, it's bidirectional: parse a cron expression into field states, and generate a cron expression from field states.

function buildExpression() {
  return fieldStates.map(state => {
    switch (state.type) {
      case 'every':    return '*';
      case 'specific': return String(state.value);
      case 'range':    return `${state.from}-${state.to}`;
      case 'step':     return `*/${state.step}`;
      default:         return '*';
    }
  }).join(' ');
}

function parseExpression(expr) {
  const parts = expr.trim().split(/\s+/);
  return parts.map((part, i) => {
    if (part === '*')            return { type: 'every' };
    if (part.startsWith('*/'))   return { type: 'step', step: parseInt(part.slice(2)) };
    if (part.includes('-'))      return { type: 'range', from: parseInt(part), to: parseInt(part.split('-')[1]) };
    return { type: 'specific', value: parseInt(part) };
  });
}
Enter fullscreen mode Exit fullscreen mode

Key principle: Keep parsing and generation as pure functions. They take input, return output, touch no DOM. This makes them easy to test (even manually in the console) and easy to reason about.


Step 4: Theme Switching (The Right Way)

Dark mode is expected in 2026. Here's the minimal implementation:

/* In your CSS */
:root {
  --bg: #ffffff;
  --text: #1a1a2e;
  --surface: #f5f5f5;
  --accent: #6366f1;
}

[data-theme="dark"] {
  --bg: #0f172a;
  --text: #f1f5f9;
  --surface: #1e293b;
  --accent: #818cf8;
}
Enter fullscreen mode Exit fullscreen mode
function initTheme() {
  const saved = localStorage.getItem('theme');
  const preferred = window.matchMedia('(prefers-color-scheme: dark)').matches 
    ? 'dark' : 'light';
  document.documentElement.dataset.theme = saved || preferred;
}

function toggleTheme() {
  const current = document.documentElement.dataset.theme;
  const next = current === 'dark' ? 'light' : 'dark';
  document.documentElement.dataset.theme = next;
  localStorage.setItem('theme', next);
}
Enter fullscreen mode Exit fullscreen mode

Three things to note:

  1. CSS custom properties do all the heavy lifting. One attribute change repaints everything.
  2. Respect system preference as the default.
  3. Persist choice in localStorage.

Step 5: Copy to Clipboard (With Feedback)

Every developer tool needs a copy button. Here's the pattern that works everywhere:

async function copyToClipboard() {
  const text = cronInput.value;

  try {
    await navigator.clipboard.writeText(text);
    showFeedback('Copied!');
  } catch {
    // Fallback for older browsers or non-HTTPS
    const textarea = document.createElement('textarea');
    textarea.value = text;
    textarea.style.position = 'fixed';
    textarea.style.opacity = '0';
    document.body.appendChild(textarea);
    textarea.select();
    document.execCommand('copy');
    document.body.removeChild(textarea);
    showFeedback('Copied!');
  }
}

function showFeedback(msg) {
  copyFeedback.textContent = msg;
  copyFeedback.classList.add('visible');
  setTimeout(() => copyFeedback.classList.remove('visible'), 2000);
}
Enter fullscreen mode Exit fullscreen mode

The navigator.clipboard API requires HTTPS or localhost. The fallback handles HTTP and older browsers.


Step 6: SEO for Free Tools

If nobody finds your tool, it doesn't matter how good it is. Minimal SEO checklist:

<head>
  <title>CronMaker — Cron Expression Generator</title>
  <meta name="description" content="Build and decode cron 
    expressions visually. Free, no signup.">
  <link rel="canonical" href="https://your-url.github.io/cronmaker/">

  <!-- Structured data tells Google "this is an app" -->
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "WebApplication",
    "name": "CronMaker",
    "description": "Visual cron expression generator",
    "url": "https://your-url.github.io/cronmaker/",
    "applicationCategory": "DeveloperApplication",
    "offers": { "@type": "Offer", "price": "0" }
  }
  </script>
</head>
Enter fullscreen mode Exit fullscreen mode

And include a content section below the tool with relevant keywords — the "cheat sheet" pattern works perfectly for dev tools. Google indexes it, users find it useful, everyone wins.


Deployment: GitHub Pages

git init
git add .
git commit -m "Initial commit"
gh repo create my-tool --public --source=. --push
# Enable GitHub Pages in repo settings → main branch
Enter fullscreen mode Exit fullscreen mode

Your tool is now live at https://username.github.io/my-tool/. Free hosting, free SSL, free CDN, zero config.

Total cost: $0/month. Forever.


The Complete Init Pattern

Here's how all the pieces connect:

(() => {
  'use strict';

  // 1. Constants
  const FIELDS = [ /* ... */ ];
  const PRESETS = [ /* ... */ ];

  // 2. State
  let fieldStates = [];

  // 3. DOM refs
  const $ = s => document.querySelector(s);
  const input = $('#input');
  const output = $('#output');

  // 4. Pure logic functions
  function parse(expr) { /* ... */ }
  function generate(states) { /* ... */ }
  function toHumanReadable(expr) { /* ... */ }

  // 5. Render functions
  function renderBuilder() { /* ... */ }
  function renderOutput() { /* ... */ }
  function updateAll() {
    renderOutput();
    renderBuilder();
  }

  // 6. Event handlers
  function onInput() {
    fieldStates = parse(input.value);
    updateAll();
  }

  // 7. Init
  function init() {
    initTheme();
    input.addEventListener('input', onInput);
    copyBtn.addEventListener('click', copyToClipboard);
    updateAll();
  }

  init();
})();
Enter fullscreen mode Exit fullscreen mode

This pattern scales surprisingly well. CronMaker is ~450 lines of JS. JSONPretty is similar. RegexLab is under 300.


When NOT to Use This Pattern

Be honest about the limitations:

  • Multi-page apps → You'll want a router, which means a framework
  • Complex forms with validation → React Hook Form exists for a reason
  • Real-time collaboration → You need WebSockets and state sync
  • Apps with auth → You need a backend anyway

But for the vast universe of single-purpose tools? Three files, zero dependencies, deploy and forget.


Tools Built with This Pattern

I've built a collection of developer tools using exactly this architecture:

All open source. All zero dependencies. All under 500 lines of JS.


Building something with this pattern? I'd love to see it. Drop a link in the comments.

Top comments (0)