
Jurij Tokarski
45 Tabs I Stopped Opening
A JWT decoder, a mesh gradient engine, an animation system, and everything in between. Three of them outgrew the toolkit.
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
Encoding — Base64, JWT Decoder, Image to Base64, Encrypt / Decrypt, Hash Generator
JSON & YAML — JSON Formatter, JSON ↔ YAML, YAML Validator
Markdown — Markdown Preview, Text Diff, HTML ↔ Markdown, Markdown to PDF, Markdown to DOCX, CSV Editor
Images — QR Code, Barcode, Image Converter, Favicon Generator, SVG Optimizer, Placeholder Images, Aspect Ratio
Design — Mesh Gradient, CSS Cover Art, Color Converter, Text to Gradient
Charts — Bar Chart Race, Line Chart Race, Bubble Chart Race, Area Chart Race
Network — DNS Lookup, CORS Tester, SSL Checker, OG Tag Validator, HTTP Status Codes, Robots.txt Validator, Sitemap Validator, User Agent Parser
Text — Regex Tester, Case Converter, Slug Generator, Word Counter, Copy Paste Characters
Generators — UUID, 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;
}
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;
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);
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
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;
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.
Subscribe to the newsletter:
About Jurij Tokarski
Hey 👋 I'm Jurij. I run Varstatt and create software. Usually, I'm deep in the work shipping for clients or building for myself. Sometimes, I share bits I don't want to forget: mostly about software, products and self-employment.
x.comlinkedin.commedium.comdev.tohashnode.devjurij@varstatt.comRSS