---
title: "Audit checklist: 16 points for registry/component-library source (components)"
description: "Run this after fetching a registry item's JSON (SKILL.md → fetch commands) and before rewriting it."
canonical: https://void-design.vercel.app/docs/components/audit-checklist
lastModified: 2026-09-16
---

# Audit checklist: 16 points for registry/component-library source

Run this after fetching a registry item's JSON (`SKILL.md` → fetch commands) and before rewriting it. Every **fail** becomes one line in the rewrite plan (template at the bottom). `rg`/`jq` here are triage greps over the fetched source — fast, but pattern-matching, not real parsing. `void lint .` runs the same checks as real AST rules on committed files and is the ground truth; use it once the rewrite lands in the repo. Bug write-ups per pattern (with the library/component that shipped them): `references/upstream-bugs.md`. Paste-ready replacements: `references/recipes.md`.

**Re-verify rule ids before citing them elsewhere** — the rules files change:
```bash
grep -n 'id: "' packages/cli/src/rules/*.ts          # design/seo/perf/a11y/geo ids, verbatim
grep -oE 'r\("[a-z0-9-]+"' packages/cli/src/rules/lint.ts   # lint/* ids (prefix is added by the r() helper)
bun packages/cli/src/index.ts rules <id>              # print one rule's why/fix/example
bun packages/cli/src/index.ts rules                   # list every rule
```

**Registry item metadata in one shot:**
```bash
curl -sL <item>.json | jq '{dependencies, registryDependencies, css, cssVars}'
curl -sL <item>.json | jq -r '.files[].content' > /tmp/item.tsx   # source to a scratch file for rg
diff <(jq -r '.dependencies[]' /tmp/item.json) <(jq -r '.dependencies|keys[]' package.json)  # phantom/renamed deps
```

## 1. Licence

**Check:** which licence class the item's source repo/registry falls under, and what the actual file header says (registries sometimes ship code the docs site doesn't mention).
```bash
head -20 /tmp/item.tsx | grep -i 'copyright\|license'
curl -sL <repo>/LICENSE            # or LICENSING.md (coss/Origin UI), LICENCE.md (motion-primitives)
```
**Pass:** MIT/Apache/Unlicense → may vendor with notice kept. Commons Clause (React Bits, Animate UI), proprietary (Aceternity, Hover.dev) or attribution-required (Skiper free) → clean-room only. Full matrix: `SKILL.md` → Licence matrix, `references/catalogue.md`.
**Rule:** none (legal, not lintable).
**Fix:** MIT-class → vendor + fix every other fail below, keep the header. Anything else → write the 5–10 line spec (`SKILL.md` → Rewrite onto void tokens), close the source, build clean-room.

## 2. SSR safety

**Check:** browser globals or non-deterministic values evaluated at module scope or during render (outside `useEffect`/event handlers).
```bash
rg -n 'window\.|document\.|navigator\.|matchMedia\(|localStorage' /tmp/item.tsx
rg -n 'Math\.random\(\)|Date\.now\(\)|new Date\(\)' /tmp/item.tsx
rg -n -B2 'window\.|Math\.random' /tmp/item.tsx | rg -v 'useEffect|addEventListener\('   # crude: flags hits not near an effect
```
**Pass:** zero hits outside `useEffect`/`useSyncExternalStore`/event handlers.
**Rule:** `lint/browser-global-in-render`, `lint/random-in-render`.
**Fix:** read browser APIs in an effect or `useSyncExternalStore` (server snapshot = the neutral default); replace `Math.random()` with a value seeded from the index. Pattern group: `references/upstream-bugs.md` §3.

## 3. Server HTML is complete

**Check:** headline, numbers and links are present and visible in the raw HTML, not hidden until JS runs.
```bash
rg -n 'opacity-0|opacity:\s*0|initial=\{\{\s*opacity:\s*0|initial="hidden"' /tmp/item.tsx
curl -s <dev-url> | grep -oE '<h1[^>]*>.*</h1>'          # headline present, no opacity-0 class
curl -s <dev-url> | grep -o '12,400'                      # the real number, not "0" or empty
curl -s <dev-url> | grep -c '<a '                          # links present without JS
```
**Pass:** real content in the curl output; no `opacity-0`/`initial="hidden"` on above-the-fold text; a numeric component never SSRs `0`/`""`.
**Rule:** none static (grep + curl only); the runtime symptom shows up as `perf/lcp-slow` once it ships.
**Fix:** SSR the final value/text and animate position only (`RevealText`), or animate a number only if it starts offscreen (`NumberTicker`). Pattern group: `references/upstream-bugs.md` §2.

## 4. Cleanup is 1:1

**Check:** every subscription, observer, timer or library instance has a matching teardown.
```bash
rg -c 'addEventListener\(' /tmp/item.tsx;      rg -c 'removeEventListener\(' /tmp/item.tsx
rg -c 'requestAnimationFrame\(' /tmp/item.tsx; rg -c 'cancelAnimationFrame\(' /tmp/item.tsx
rg -c 'setInterval\(|setTimeout\(' /tmp/item.tsx; rg -c 'clearInterval\(|clearTimeout\(' /tmp/item.tsx
rg -n 'new Lenis\(|createGlobe\(' /tmp/item.tsx; rg -n '\.destroy\(\)' /tmp/item.tsx
rg -c '\.on\(' /tmp/item.tsx;                   rg -c '\.off\(' /tmp/item.tsx
```
**Pass:** each "add" count has a matching "remove" count (not just present somewhere — check it's in the same effect's cleanup return).
**Rule:** `lint/listener-without-cleanup`, `lint/raf-without-cancel`, `lint/timer-in-render`, `lint/unload-listener` (an `unload` listener specifically blocks bfcache).
**Fix:** return the remove/disconnect/cancel/clear/destroy from the same `useEffect`, or pass `{ signal }` from one `AbortController` and abort it. Pattern group: `references/upstream-bugs.md` §4.

## 5. Hooks correctness

**Check:** hooks called unconditionally at the top level; no component/MotionValue-subscription creation during render; stable effect deps.
```bash
rg -n '&&\s*<.*use[A-Z]\w*\(|\?\s*use[A-Z]\w*\(|style=\{\{[^}]*use[A-Z]\w*\(' /tmp/item.tsx   # AST hint, approximate: use[A-Z]… inside &&/ternary/JSX literal
rg -n 'm\.create\(|createElement\(' /tmp/item.tsx        # component defined/created in render
rg -n '\.on\((["'\'']change)' /tmp/item.tsx              # MotionValue .on outside useMotionValueEvent
rg -n '\[.*ref\.current.*\]' /tmp/item.tsx                # ref.current in a deps array
```
**Pass:** no hooks inside `&&`/ternary/JSX prop literals; `m.create()`/component factories only at module scope or `useMemo`; MotionValue subscriptions via `useMotionValueEvent`; refs never appear inside `[]` deps.
**Rule:** `lint/conditional-hook`, `lint/motionvalue-subscribe-in-render`, `lint/svg-global-id` (a related AST smell: a hardcoded SVG `id` instead of `useId()`, breaks the 2nd instance on the page).
**Fix:** hoist the hook call, feed the result in conditionally; `useMotionValueEvent(value, 'change', cb)`; `useId()`-scope any SVG `<filter>`/`<linearGradient>` id. Pattern group: `references/upstream-bugs.md` §5.

## 6. Zero per-frame React work

**Check:** no `setState` inside a pointer/scroll/rAF handler; no allocation inside a loop.
```bash
rg -n -B3 'set[A-Z]\w*\(' /tmp/item.tsx | rg -B3 'onPointerMove|onMouseMove|onScroll|requestAnimationFrame'   # AST hint: setState reachable from a per-frame handler
rg -n -B2 'new Intl\.NumberFormat|new Color\(' /tmp/item.tsx    # then confirm the call site is inside rAF/.on('change')/useAnimationFrame
rg -n 'getBoundingClientRect\(\)' /tmp/item.tsx                 # forced layout reads; flag if inside a loop/handler
```
**Pass:** pointer/scroll paths write to a ref's `style`/CSS custom property or a MotionValue `.set()`, never `setState`; formatters/colour objects built once (module scope or `useMemo`), not per frame.
**Rule:** `lint/setstate-per-pointer-move`, `lint/allocation-per-frame`.
**Fix:** ref + one rAF writing `el.style.translate`/`--x` (recipe 2 `SpotlightCard`, recipe 7 `Magnetic`); hoist `Intl.NumberFormat` to `useMemo`. Pattern group: `references/upstream-bugs.md` §6.

## 7. Reduced motion

**Check:** the file honours the OS preference, and the honouring actually disables the loop (not just present).
```bash
rg -c 'prefers-reduced-motion|useReducedMotion|motion-reduce:|motion-safe:' /tmp/item.tsx
```
**Pass:** count > 0, **and** manually confirm it gates the actual animation (a global CSS reduced-motion rule alone doesn't cover JS rAF loops or inline `style` transforms — those need their own check).
**Rule:** `lint/no-reduced-motion`, `smooth/reduced-motion-ignored`.
**Fix:** a static finished frame with content intact; disable via `motion-reduce:`/`@media (prefers-reduced-motion: reduce)` for CSS, `useReducedMotion()`/`<MotionConfig reducedMotion="user">` for JS. This is the single most common fail (~95% of registry items) — never skip it. Cross-cutting across every group in `references/upstream-bugs.md`.

## 8. Offscreen pause

**Check:** infinite animations stop when off-screen; JS loops also stop on tab hide.
```bash
rg -n 'IntersectionObserver|useInView|data-fx' /tmp/item.tsx
rg -n 'visibilitychange' /tmp/item.tsx    # JS rAF/WebGL loops only
rg -n 'repeat:\s*Infinity|infinite\b' /tmp/item.tsx   # candidates that need one of the above
```
**Pass:** every `repeat: Infinity`/`infinite`/rAF loop is paired with an IntersectionObserver, `useInView`, or the shared `[data-fx]` gate; JS loops also react to `visibilitychange`. Pausing must **cancel** the loop (`cancelAnimationFrame`), not just skip drawing — a skipped-but-still-scheduled rAF still burns a callback every frame.
**Rule:** `smooth/raf-loop-idle`.
**Fix:** CSS-driven effects get `data-fx` + the shared `FxGate` (recipes.md §0); JS/WebGL loops cancel on `visibilitychange` and IntersectionObserver both. Pattern group: `references/upstream-bugs.md` §7.

## 9. DPR / resolution cap

**Check:** canvas/WebGL pixel ratio is capped, not raw or hardcoded.
```bash
rg -n 'devicePixelRatio' /tmp/item.tsx
rg -n 'devicePixelRatio\s*[,)]\s*1\.5|Math\.min\(.*devicePixelRatio' /tmp/item.tsx   # the capped form
rg -n 'devicePixelRatio:\s*2|width\s*\*\s*2|height\s*\*\s*2' /tmp/item.tsx           # hardcoded 2x (Magic UI globe)
```
**Pass:** `Math.min(devicePixelRatio, 1.5)` (1 for full-bleed backdrops) read inside an effect, never a bare or hardcoded `2`; full-bleed shader render scale ≤0.6.
**Rule:** `lint/hardcoded-device-pixel-ratio`.
**Fix:** cap and resize via `ResizeObserver`, never at module scope. Pattern group: `references/upstream-bugs.md` §10.

## 10. Frame-rate cap

**Check:** ambient T3/T4 loops throttle to ≤30fps instead of running the full rAF rate.
```bash
rg -n 'requestAnimationFrame' /tmp/item.tsx    # then read the callback: does it throttle, or draw every frame unconditionally?
rg -n 'frameloop\s*=\s*.always.|fpsLimit|1000\s*/\s*[0-9]+' /tmp/item.tsx
```
**Pass:** ambient backgrounds (aurora, globes, particle fields) skip frames to land at ≤30fps; foreground/interactive canvas may run full rate. No static grep proves this — read the draw loop.
**Rule:** none static; verify with `void smooth <url> --routes <route>` (dropped-frame % and the profiler timeline).
**Fix:** accumulate elapsed time and only draw past a `1000/30` threshold. Pattern group: `references/upstream-bugs.md` §10.

## 11. Properties animated

**Check:** continuous motion touches only `transform`/`opacity`; anything else is one-shot or a single small element.
```bash
rg -n '@keyframes' -A6 /tmp/item.tsx   # read the keyframe body
rg -n 'width:|height:|top:|left:|margin|padding' /tmp/item.tsx        # layout properties in an animated block
rg -n 'background-position|filter:|box-shadow|linearGradient.*x1|y1' /tmp/item.tsx   # paint properties
rg -n 'transition:\s*all|transition-all\b' /tmp/item.tsx
```
**Pass:** no `width/height/top/left/margin/padding` inside a `repeat`/`infinite` block; paint properties (`filter`, `background-position`, `box-shadow`, SVG gradient coordinates) animate once or on one small element only, never continuously full-bleed; no `transition: all`.
**Rule:** `lint/animate-layout-prop`, `smooth/animate-layout-property`, `lint/transition-all`, `smooth/transition-all`.
**Fix:** `translate`/`scale`/`rotate`/`opacity` only; `grid-template-rows: 0fr → 1fr` for accordions; a static overlay instead of `mask-image` over animated children (measured: 10% dropped frames); `will-change: transform` on the one paint-animated element, never many. Pattern group: `references/upstream-bugs.md` §8 — also see the recipes.md aurora/shine gotcha (never stack a paint animation over an animated backdrop: 14–46% dropped frames measured).

## 12. Accessibility

**Check:** decorative layers hidden; duplicated content hidden from AT; real interactive elements; hover has a keyboard/focus equivalent; long-moving content can pause.
```bash
rg -c '\.map\(' /tmp/item.tsx                                    # candidates for duplicated marquee/loop content
rg -n 'aria-hidden' /tmp/item.tsx                                 # present on every duplicate + every decorative layer?
rg -n 'inert' /tmp/item.tsx                                       # duplicates need aria-hidden AND inert (aria-hidden alone still leaves links focusable)
rg -n '<div[^>]*onClick|<span[^>]*onClick' /tmp/item.tsx          # clickable non-interactive element
rg -n '<button(?![^>]*type=)' /tmp/item.tsx                       # <button> missing an explicit type (approximate)
rg -n ':hover|whileHover|group-hover' /tmp/item.tsx;  rg -n 'focus-visible|:focus\b' /tmp/item.tsx   # hover without a focus counterpart
```
**Pass:** decorative layers `aria-hidden`; duplicated content (marquee copies, `TextRoll`) has both `aria-hidden` and `inert`; clickable elements are real `<a href>`/`<button type>`; canvas/SVG art either `aria-hidden` or `role="img"` + label; every `:hover`/`whileHover` has a `:focus-visible` equivalent; content that moves for >5s can be paused (WCAG 2.2.2).
**Rule:** `lint/duplicate-children-not-hidden`, `lint/div-button`, `lint/button-missing-type`, `lint/svg-global-id`, `a11y/target-size`, `a11y/focus-not-visible`.
**Fix:** `Array.from({length: repeat}, (_, i) => <div aria-hidden={i>0} inert={i>0}>…)`; `<button type="button">`; pair hover styles with `focus-visible:`. Pattern group: `references/upstream-bugs.md` §11.

## 13. Hover gating

**Check:** hover-driven motion is gated to real pointers, and pointer handlers check the pointer type.
```bash
rg -n '@media\s*\(hover:\s*hover\)|hover:hover.*pointer:fine' /tmp/item.tsx
rg -n 'pointerType' /tmp/item.tsx
rg -n ':hover|whileHover|group-hover' /tmp/item.tsx    # candidates that should be inside the gate above
```
**Pass:** every hover-triggered animation sits under `@media (hover: hover) and (pointer: fine)` (Tailwind: `hover:hover:` variant stacking, or a plain media query in CSS); pointer event handlers branch on `e.pointerType !== 'touch'` where a mouse-only effect (magnetic, spotlight) would otherwise fire from a tap.
**Rule:** none dedicated; covered by manual review plus `lint/setstate-per-pointer-move` for the handler itself.
**Fix:** wrap the CSS in the media query; check `pointerType` before running pointer-follow logic. Pattern noted in `references/upstream-bugs.md` §11 (hover-only pause on the marquee is the same root cause: hover semantics applied without a touch/keyboard fallback).

## 14. Dependency weight and duplicates

**Check:** the item's dependencies fit the target tier, aren't phantom/renamed, and don't duplicate a library already in the app.
```bash
jq '.dependencies' /tmp/item.json
diff <(jq -r '.dependencies[]?' /tmp/item.json | sort) <(jq -r '.dependencies|keys[]' package.json | sort)   # phantom deps not in package.json
rg -n 'framer-motion' /tmp/item.tsx     # duplicate runtime if the app already uses `motion`
rg -n '@base-ui-components/react' /tmp/item.tsx   # renamed package (now @base-ui/react) — duplicate, stale
rg -n 'three|@react-three' /tmp/item.tsx          # three/R3F for what might be a 2D quad
rg -n 'face-api' /tmp/item.tsx                    # ~700KB bundle bomb (React Bits GridScan)
```
**Pass:** deps map to the tier the effect actually needs (nothing/CSS → T1, `motion` → T2, `getContext('2d')` → T3, `ogl`/`three`/`cobe` → T4 — see `SKILL.md` tier table); no `framer-motion` next to `motion`, no `@base-ui-components/react` next to `@base-ui/react`, no three.js for a single 2D quad.
**Rule:** `lint/heavy-import`, `smooth/multiple-webgl-contexts`, `lint/webgl-in-map` (a WebGL canvas rendered inside `.map()` — browsers evict contexts past ~8–16 per page).
**Fix:** down-tier (WebGL Aurora → CSS gradients, recipe 9) or swap for the matching library (`@number-flow/react`, `cobe`, `torph` — `SKILL.md` decision tree step 3). Also watch for DOM/node explosions from the same audit pass (`Array(n).map()` of animated nodes, e.g. Magic UI dot-pattern's ~5,130 `<circle>`s) — same fix direction, lower node count via CSS. Pattern groups: `references/upstream-bugs.md` §9, §10.

## 15. Tokens

**Check:** no raw colour literals or default Tailwind palette classes; motion timing uses tokens, not magic numbers.
```bash
rg -n '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' /tmp/item.tsx
rg -n '\b(neutral|zinc|gray|slate|purple|indigo|violet|pink)-[0-9]{2,3}\b' /tmp/item.tsx
rg -n 'duration-\[[0-9]|ease-\[cubic-bezier|[0-9]{2,4}ms' /tmp/item.tsx   # hardcoded durations/eases instead of --duration-*/--ease-*
```
**Pass:** every colour is a `var(--color-*)`/semantic utility (`bg-surface`, `text-fg-muted`, `border-line`); no default-palette classes; motion timing reads `--ease-*`/`--duration-*`.
**Rule:** `lint/hardcoded-colors`, `design/purple-gradient` (the violet→indigo hero trope), `design/gradient-text` (background-clip:text headlines), `design/multiple-accents`.
**Fix:** run the registry→void token table in `SKILL.md` → Rewrite onto void tokens; one hue via `var(--brand)` + `color-mix(in oklab, var(--brand) 20%, transparent)`.

## 16. Tailwind v4 correctness

**Check:** v3-only syntax that silently compiles to nothing or the wrong value under v4.
```bash
rg -n 'bg-gradient-to-|flex-shrink-0|flex-grow-0' /tmp/item.tsx
rg -n 'duration-\[--|ease-\[--|[a-z-]+-\[--[a-z-]+\]' /tmp/item.tsx     # v3 `[--var]` arbitrary-var syntax; v4 needs `(--var)`
rg -n '\bshadow\b(?!-)|\brounded\b(?!-)|\boutline-none\b' /tmp/item.tsx  # bare/renamed v3 utilities
rg -n 'animate-[a-z-]+' /tmp/item.tsx                                    # then confirm each has matching css/cssVars (jq check above) or a `--animate-*` in @theme
ls tailwind.config.* 2>/dev/null && rg -L '@config' src/**/*.css        # JS config present but never loaded from the CSS entry
```
**Pass:** `bg-linear-to-*` not `bg-gradient-to-*`; `shrink-0` not `flex-shrink-0`; `duration-(--x)` not `duration-[--x]`; `shadow-xs`/`rounded-xs`/`outline-hidden` (v4 renames); every custom `animate-*` class has matching CSS shipped with the item or defined in `@theme`; a `tailwind.config.*`, if present, is loaded via `@config` or has been migrated into `@theme`.
**Rule:** `lint/tw-unknown-class`, `lint/tw-v3-arbitrary-var`, `lint/tw-v3-renamed`, `lint/tw-js-config-ignored`.
**Fix:** rename per the v3→v4 map in `SKILL.md` → Rewrite onto void tokens; move keyframes into `@layer components` or `@theme`; `next build` and grep the compiled CSS for the class to confirm it exists. Pattern group: `references/upstream-bugs.md` §1.

## Rewrite plan template

Fill this in per item, in the PR/commit description, after running all 16 points:

| # | Point | Fail found | Fix applied | Rule id(s) closed |
|---|---|---|---|---|
| 2 | SSR safety | `navigator.userAgent` at module scope | moved into `useSyncExternalStore`, server snapshot `false` | `lint/browser-global-in-render` |
| 7 | Reduced motion | no `prefers-reduced-motion` anywhere | added a finished static frame + `motion-reduce:` | `lint/no-reduced-motion`, `smooth/reduced-motion-ignored` |
| 12 | Accessibility | 3 marquee copies, no `aria-hidden`/`inert` | `aria-hidden={i>0}` + `inert={i>0}` on copies 2–3 | `lint/duplicate-children-not-hidden` |
| 15 | Tokens | `#9c40ff → #ffaa40` gradient | one hue via `var(--brand)` | `lint/hardcoded-colors`, `design/purple-gradient` |

An item with zero fails after a real MIT-class licence may be vendored as-is (still keep the copyright header). Anything with a licence fail skips straight to clean-room regardless of how many other points pass.
