Most small-business websites are not broken. They load, the links work, the phone number dials. They are, in the word everyone uses, "fine." The problem is that fine is now the floor, not the ceiling. When every competitor's site also works, the thing that separates a site that feels cheap from one that feels trustworthy is craft: the small, mostly invisible details that add up to "someone cared." Emil Kowalski, the design engineer behind Sonner and Vaul, has spent years writing down exactly what those details are. He published them as a set of AI-readable skills, and you can point an AI coding assistant at that repo, audit your own site against it, and fix the gaps in an afternoon.
This post is the generalized playbook. What the repo is, the short checklist it encodes, how to audit any site against it in about five greps, and the exact copy-paste fixes. None of this requires a redesign. It is your existing site, with the finish it should have had.
What the repo is
Emil's skills live at github.com/emilkowalski/skills. It is a collection of "skills," which are just Markdown files written so that an AI assistant can load them as instructions. There are eight of them, and they fall into three buckets:
- The philosophy.
emil-design-engandapple-designencode the reasoning: why a button should respond to a press, what easing curve to use and when, how translucency conveys hierarchy, how typography tracking changes with size. These are the "why." - The review tools.
review-animations,improve-animations, andfind-animation-opportunitiesare graders. They read your code and flag what falls short of the bar, or find the few places motion would genuinely help. Their default posture is restraint: the best animation is very often no animation. - The reference.
animation-vocabularyturns "the bouncy thing when a menu opens" into the actual term, andpick-ui-libraryis a taste-driven shortlist (Sonner for toasts, Base UI for accessible primitives, and so on).
You do not have to be a designer to use any of it. You clone the repo, tell your AI assistant to read the skills, and then ask it to audit a file or a URL against them. The value is that the taste is already encoded, so a model with no taste of its own can still apply Emil's.
The through-line of the whole repo, borrowed from Paul Graham: "All those unseen details combine to produce something that's just stunning, like a thousand barely audible voices all singing in tune."
The checklist it encodes
Strip the eight skills down to what you can actually check on a real site and you get a short, opinionated list. This is the bar.
- Press feedback. Every pressable element (button, link that acts like a button, card you can tap) should shrink slightly when pressed:
transform: scale(0.97)on:active, with a fast transition. Its absence is the single most common gap, and on a phone, where there is no hover, a button with no press state feels dead. - Real easing. The browser's built-in
easeis too weak to feel intentional. Use a strong custom curve. Entrances and exits wantease-out; things moving or morphing on screen wantease-in-out; hovers and color changes want plainease. - No
transition: all. It animates every property, including layout ones like width and margin, off the GPU, and catches things you never meant to move. Name the properties. - Sub-300ms UI motion. A 180ms dropdown feels more responsive than a 400ms one. Keep interface animations short; save the longer beats for rare, first-time, marketing moments.
- Gate hover motion. Wrap any hover that moves or scales an element in
@media (hover: hover) and (pointer: fine). Without it, tapping on a phone triggers:hoverand the element sticks in its lifted state until you tap elsewhere. - Reduced motion, gently. Honor
prefers-reduced-motion, but "gentler," not "off." Keep opacity and color fades that aid comprehension; drop the movement. - Text that resizes. If your font sizes are all in
px, the browser's own text-size setting cannot scale them, which fails WCAG 1.4.4. Useremso the user's preference is the base. - Real installability. If you ship a web app manifest, ship the rest: a 192 and 512 icon, a maskable variant, a registered service worker, and a theme color. Half a PWA is a promise the browser silently drops.
- Tabular numbers. Prices, counters, and timers should use
font-variant-numeric: tabular-numsso the digits do not jitter and columns line up.
Nine checks. The beautiful part is that most sites pass the hard ones (semantic HTML, valid schema, decent color) and fail the cheap ones (press feedback, easing, hover gating). The cheap ones are a few lines of CSS.
How to audit any site in five greps
You do not need to read the whole stylesheet. Point these searches at your CSS (and the served HTML) and you have a diagnosis in under a minute.
# 1. Press feedback: is there any :active state at all?
grep -c ':active' styles.css
# 2. The transition:all smell
grep -oiE 'transition:\s*all' styles.css | wc -l
# 3. Hover gating: any @media (hover: hover)?
grep -c 'hover:hover' styles.css
# 4. Text-resize bug: px font-sizes vs rem
grep -oiE 'font-size:[^;{}]*[0-9.]+px' styles.css | wc -l # want ~0
grep -oiE 'font-size:[^;{}]*rem' styles.css | wc -l # want most
# 5. PWA completeness: manifest linked but no service worker?
grep -oiE 'rel="manifest"' index.html
grep -c 'serviceWorker' index.html
A typical result on a "fine" small-business site: zero :active rules, a dozen transition: all, no hover:hover, a manifest with no service worker. Every one of those is a known fix with a known value. If your AI assistant has Emil's skills loaded, you can hand it the same output and it will produce the same diagnosis with the exact line numbers.
The universal fix: one appended craft block
Here is the highest-leverage change you can make to almost any site. It adds press feedback and strong easing to your buttons, and neutralizes sticky hovers on touch, in one block you paste at the end of your main stylesheet. Because it comes last, it wins the cascade over the existing rules without you editing them.
/* Craft upgrade: press feedback + strong easing + gated hover */
:root { --ease-out: cubic-bezier(0.23, 1, 0.32, 1); }
/* Every pressable thing gets a fast transform channel + a press state.
List your real button/link classes here. */
.btn, .btn-primary, .nav-cta, .card, .faq-question {
transition: transform 140ms var(--ease-out),
background .15s, color .15s, border-color .15s, box-shadow .15s;
}
.btn:active, .btn-primary:active, .nav-cta:active,
.card:active, .faq-question:active { transform: scale(0.97); }
/* Feedback is not decoration, so keep it under reduced motion,
but drop it for the few users who ask for zero movement. */
@media (prefers-reduced-motion: reduce) {
.btn:active, .btn-primary:active, .nav-cta:active { transform: none; }
}
/* On touch, :hover latches after a tap. Cancel any hover-transform there
so cards and buttons don't stick in a lifted state. */
@media (hover: none) {
.btn:hover, .card:hover, .nav-cta:hover { transform: none; }
}
Swap in your own class names. The @media (hover: none) trick is the safe way to gate hovers without rewriting each rule: it only cancels the transform on touch devices, so your desktop hover is untouched. Setting transform: none on a selector that has no hover transform is harmless, so you can be generous with the list.
The text-resize fix
If your audit found a pile of px font sizes, this is a genuine accessibility bug, not a nitpick, and it is mechanical to fix. Every font-size: Npx becomes font-size: (N/16)rem, leaving the root at the browser default so the user's text-size setting becomes the base. Do not convert padding or borders; only font sizes need to scale. A tiny script handles a whole stylesheet:
// node fix-fontsize.mjs styles.css (writes a .bak first)
import { readFileSync, writeFileSync, copyFileSync } from 'node:fs';
const f = process.argv[2];
const src = readFileSync(f, 'utf8');
const out = src.replace(/font-size\s*:\s*([^;}{]+)/gi, (m, val) =>
'font-size:' + val.replace(/(\d*\.?\d+)px/g, (_, n) =>
+(parseFloat(n) / 16).toFixed(4) + 'rem'));
copyFileSync(f, f + '.bak');
writeFileSync(f, out);
At the default 16px root this renders identically to before, so there is zero visual risk, but now the text honors the browser and OS text-size settings. The same bug shows up in the inline <style> of a single-page app, where the fix is the same two or three edits by hand.
Finishing the PWA
If your manifest is linked but nothing registers a service worker, you have advertised an app the browser cannot install. Two small files close the gap. First a registration snippet loaded on every page (keep it in an external file if your site uses a strict content-security policy that blocks inline scripts):
// sw-register.js
if ('serviceWorker' in navigator) {
addEventListener('load', () =>
navigator.serviceWorker.register('/sw.js').catch(() => {}));
}
Then a minimal service worker at the site root that serves the shell offline. Network-first for pages so content stays fresh, cache for static assets:
// sw.js (must live at the root so its scope is the whole site)
const V = 'site-v1';
const SHELL = ['/', '/styles.css'];
self.addEventListener('install', (e) => e.waitUntil(
caches.open(V).then((c) => Promise.all(SHELL.map((u) => c.add(u).catch(() => {}))))
.then(() => self.skipWaiting())));
self.addEventListener('activate', (e) => e.waitUntil(
caches.keys().then((k) => Promise.all(k.filter((x) => x !== V).map((x) => caches.delete(x))))
.then(() => self.clients.claim())));
self.addEventListener('fetch', (e) => {
const r = e.request; if (r.method !== 'GET') return;
if (r.mode === 'navigate') { e.respondWith(fetch(r).catch(() =>
caches.match(r).then((m) => m || caches.match('/')))); return; }
e.respondWith(caches.match(r).then((m) => m || fetch(r)));
});
Add a maskable icon to the manifest so the mark fills the Android adaptive shape instead of landing letterboxed on a white plate, and you have a real, installable app. If you want the full case for whether a PWA is worth it at all, I wrote that up separately in Why Add a PWA to Your Site.
Where the site type changes the mechanics, not the fixes
The fixes are the same everywhere. How you apply them depends on how the site is built.
- A static multi-page site (hand-written HTML, or a folder of pages): put the craft block in one shared stylesheet, or a new
craft.cssyou link on every page. Register the service worker from a script that is already loaded site-wide. One edit reaches every page. - A templated site (Eleventy, Astro, Hugo, anything with a base layout): the shared layout and the global CSS are your leverage. A fix to
base.njkandstyle.csspropagates to every page at once. Add the service worker file to your build's passthrough so it lands at the root. - A single-page app: the CSS lives in one place already. The service worker usually exists; the common gaps are px font sizes in the inline styles and press feedback on the app's own controls.
In every case the trick is to find the shared layer and change it once, rather than editing pages one by one.
Restraint is the point
The most misunderstood part of Emil's work is that it is not "add more animation." Half the skills exist to talk you out of motion. The rules that matter most are the ones about when to do nothing: a command palette that opens hundreds of times a day should not animate; a data table someone is trying to read should not move for style; a keyboard shortcut should feel instant, not "delightful." The craft is knowing that press feedback on a button is worth it because it happens on every tap, and a bouncy entrance on a dashboard is not, because it gets old by the second view. When you audit your own site, reject most of what you could add. A short list of high-leverage fixes beats a long wishlist of effects, every time.
The whole loop, in order
If you want to run this on your own site today:
- Clone github.com/emilkowalski/skills and tell your AI assistant to read the skills.
- Run the five greps above against your CSS and HTML. Write down what fails.
- Paste the craft block at the end of your stylesheet with your real class names. That alone fixes press feedback, easing, and touch hovers.
- If you had
pxfont sizes, run the resize script. If your PWA was half-wired, add the two files. - Deploy, then check on a real phone: tap a button and feel it give. That is the whole test.
None of this is a rebuild, and none of it needs an agency. It is the difference between a site that works and one that feels like someone cared, and that difference is now the one customers actually notice. For the deeper reasoning behind why free, standards-grade craft beats a five-figure agency build, see Frameworkless and Vendor-Lock-Free, and for the structured-data half of "does this site look trustworthy to a machine," Schema and Structured Data for E-E-A-T.
Emil Kowalski teaches this material in depth at animations.dev; the skills repo is his philosophy distilled into a form an AI can apply. This guide generalizes a real sweep across a dozen small-business sites, all of which passed the hard fundamentals and failed the cheap, high-leverage details. Yours probably does too, and that is good news, because the cheap ones are the easy ones.