DEV Community

Jurij Tokarski
Jurij Tokarski

Posted on • Originally published at varstatt.com on

45 Tabs I Stopped Opening

The JWT decoder I used to reach for sent the token to a server. I noticed because I had DevTools open for something else and saw the POST. A JWT often carries user IDs, emails, roles, expiration data. I'd been pasting production tokens into a stranger's endpoint for months.

That was the first tool I built for the toolkit. The rest followed the same pattern: I needed something, the available options were ad-heavy or required sign-up or made network calls that didn't need to happen. A Base64 encoder doesn't need a backend. Neither does a regex tester, a color converter, or a hash generator.

There are 45 tools now. No sign-up, no tracking, no data collection. Most run entirely in the browser — a few like DNS Lookup and SSL Checker need a server call by nature.

The Catalogue

EncodingBase64, JWT Decoder, Image to Base64, Encrypt / Decrypt, Hash Generator

JSON & YAMLJSON Formatter, JSON ↔ YAML, YAML Validator

MarkdownMarkdown Preview, Text Diff, HTML ↔ Markdown, Markdown to PDF, Markdown to DOCX, CSV Editor

ImagesQR Code, Barcode, Image Converter, Favicon Generator, SVG Optimizer, Placeholder Images, Aspect Ratio

DesignMesh Gradient, CSS Cover Art, Color Converter, Text to Gradient

ChartsBar Chart Race, Line Chart Race, Bubble Chart Race, Area Chart Race

NetworkDNS Lookup, CORS Tester, SSL Checker, OG Tag Validator, HTTP Status Codes, Robots.txt Validator, Sitemap Validator, User Agent Parser

TextRegex Tester, Case Converter, Slug Generator, Word Counter, Copy Paste Characters

GeneratorsUUID, Password, Crontab, Unix Timestamp

Most are straightforward. Three outgrew the toolkit and became standalone npm packages.

Text to Gradient

The Text to Gradient tool and the Mesh Gradient Generator both needed the same thing: a way to turn an arbitrary input into a unique, stable visual. Same input, same gradient, every time. No database, no storage.

A djb2-style 32-bit hash is all it takes:

function textHash(str) {
  let hash = 5381;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) + hash) + str.charCodeAt(i);
    hash = hash >>> 0;
  }
  return hash;
}
Enter fullscreen mode Exit fullscreen mode

Everything derives from that number. hash % palettes.length selects the color palette. seededRandom(hash + layerIndex * 1000) generates position and opacity variation per layer. The same string always produces the same gradient — looks hand-crafted, costs nothing to store.

The gradients themselves are layered radial-gradient() calls. There's no mesh-gradient() in CSS. What works is stacking 6-8 radial gradients positioned at organic spots — 15%, 37%, 63%, 82% — not pure corners or centers, which look algorithmic. Each one uses a 0px first stop for a crisp center and transparent at 50% for soft falloff. The browser composites them in layer order.

background:
  radial-gradient(ellipse at 15% 20%, rgba(120, 40, 200, 0.9) 0px, transparent 60%),
  radial-gradient(circle at 80% 10%, rgba(40, 180, 220, 0.8) 0px, transparent 50%),
  radial-gradient(ellipse at 55% 75%, rgba(200, 60, 120, 0.85) 0px, transparent 55%),
  #1a0a2e;
Enter fullscreen mode Exit fullscreen mode

For tinting — hover states, borders, soft fills — color-mix() handles it without any HSL arithmetic:

background-color: color-mix(in srgb, var(--accent) 12%, white);
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
Enter fullscreen mode Exit fullscreen mode

One thing that cost me time: making these dynamic in Tailwind. A template literal like bg-[color-mix(in_srgb,${color}_12%,white)] silently produces nothing. Tailwind's compiler scans source files for complete static strings at build time. A class assembled from a variable doesn't exist as a string when the scanner runs — it gets skipped with no warning. Inline styles are the fallback for truly dynamic values.

Text to Gradient is now an npm package. It powers the default cover images across the site when a page has no custom visual. Those covers are also animated — which is where the next package came from.

Loopkit

Every tool, blog post, landing page, and discovery step on varstatt.com has an animated SVG cover — all powered by Loopkit. I had ~35 cover designs already in JSX when I started building the engine underneath them. The first decision was whether to keep composable React components or switch to schema-driven JSON.

JSON won because of output flexibility. A React component locks you into JSX. A schema is data — it can render to HTML for OG images, to SVG for exports, to CSS for emails, or to React for the live site. The core engine has no React dependency.

const cover = createCover(schema);
cover.html       // full HTML with inline styles
cover.style      // React style objects
cover.innerHtml  // just the elements
cover.hoverCss   // raw CSS rules
Enter fullscreen mode Exit fullscreen mode

Phase ordering. I had the cycle structured as: animate forward, hold final frame, fade out, loop. Loop restarts were smooth, but the first play() call snapped instantly from the held frame to frame 0. Moving the fade to the beginning of the cycle fixed it — every iteration, including the first, starts with a reverse interpolation from wherever the animation sits, then plays forward.

Hover exits. mouseenter called play(), mouseleave called reset(). The reset snapped to the static frame — functional but mechanical. A settle() method reads the live position and interpolates smoothly from there to the end state over a capped duration. The key: tracking currentAnimElapsed during active animation is what makes settle() possible. Without it, mouseleave can only snap.

Stagger math. In a staggered loop where each element has its own delay, the cycle duration isn't animDuration. It's the time until the last element finishes, plus hold time. Using just animDuration cuts off late-starting elements before they complete.

let lastFinish = 0;
for (const el of schema.elements) {
  const delay = computeDelay(el.animate.sequence ?? 0, schema.stagger ?? 0);
  const duration = el.animate.duration ?? schema.duration ?? 1;
  lastFinish = Math.max(lastFinish, delay + duration);
}
const cycleDuration = lastFinish + holdDuration;
Enter fullscreen mode Exit fullscreen mode

Re-centering all 48 schemas programmatically surfaced one more problem. The centering script computes a bounding box, then shifts coordinates to align with the canvas center. Loopkit schemas use [from, to] arrays for animated values — a bar animates with y: [247, 87]. The bbox script was reading [0], the start value. A bar starting at y=247 with height 180 gave a 427px bounding box on a 280px canvas. The fix was one index: read [1], the end state, because that's the visual rest position.

Loopkit is under 5KB with zero dependencies. It's an npm package now.

Markdown Repository

Markdown Repository began as a utility function inside this site. I query .md and .mdx files by frontmatter — filter by tags, sort by date, paginate. The API looks like Firestore's where/orderBy/limit chain. Once three of my projects used the same copy-pasted code, I extracted it into an npm package. The publish pipeline — trusted publishing with OIDC, no stored tokens — turned into its own post.

The Full List

45 tools, three npm packages. The full list is at varstatt.com/toolkit.

Top comments (0)