There is no registration point for a custom @keyframes or a named animation. theme.extend.keyframes and theme.extend.animation are accepted by the deep-merge and then never read, so animate-<name> generates nothing — silently.
Repro
import { CSSGenerator, defaultConfig } from '@cwcss/crosswind'
const g = new CSSGenerator({
...defaultConfig,
theme: {
...defaultConfig.theme,
extend: {
...defaultConfig.theme?.extend,
keyframes: { 'reveal-up': { from: { opacity: '0' }, to: { opacity: '1' } } },
animation: { reveal: 'reveal-up 1s linear both' },
},
},
})
g.generate('animate-reveal')
g.generate('animate-spin') // control
const css = g.toCSS()
css.includes('.animate-reveal') // false
css.includes('reveal-up') // false — no rule, no @keyframes
css.includes('.animate-spin') // true } control proves the method
/@keyframes\s+spin/.test(css) // true } finds what does existThe control matters: the same generator call emits .animate-spin and its @keyframes spin block, so this isn't a harness mistake — reveal-up is genuinely dropped.
Cause
dist/chunk-jbn8cyfb.js:453 emits keyframes only from a frozen built-in map:
for (let W of this.usedKeyframes) { let H = gX[W]; if (H) J.push(...) }gX contains exactly four entries — spin, ping, pulse, bounce. There is no path from theme config into it.
dist/types.d.ts:51-60 confirms the surface: Theme { colors; spacing; fontSize; fontFamily; lineHeight?; screens; borderRadius; boxShadow; extend? } — no keyframes, no animation. rg -n 'keyframes' dist/*.d.ts returns 0 hits (validated against rg -n 'shortcuts' dist/*.d.ts → 2 hits, so the search works).
CustomRule (types.d.ts:151) returns Record<string, string> — declarations only — so a custom rule can't emit an at-rule either.
Why it matters
The only way to ship a custom animation is a raw preflight string containing both the @keyframes block and a hand-written class. Ours does exactly that twice, and those hand-written classes are then invisible to the extractor, can't be purged, and don't compose with variants the way a real utility would.
One good sign: the variant half already works — motion-reduce:animate-none generates correctly. So motion-reduce:animate-reveal would compose properly the moment animate-reveal can be registered. It's only the registry that's missing.
Ask
Read theme.extend.keyframes and theme.extend.animation the way the rest of theme.extend is read, merging into the built-in map rather than replacing it, and emit @keyframes for any animation actually used.
If custom keyframes are intentionally out of scope, a build-time warning on an unresolvable animate-* would still be a large improvement over silence — right now a typo and an unregistered animation are indistinguishable, and both produce a page that just doesn't animate.
Environment
crosswind 0.2.15, consumed through @stacksjs/stx 0.2.153, Bun 1.3.1.