react-decor
Compile-time composition

Decorators that compile away

react-decor gives React function components raw @ decorators — bare, call-form, or JSX — and compiles every one into the nested JSX you would have written by hand. No HOCs, no runtime, nothing extra in the bundle.

You write

email-input.at.tsx
@Card
@Labeled({ text: 'Email address' })
export function EmailInput(props: InputProps) {
  return <input type="email" {...props} />
}

It ships as

function EmailInput_base(props: InputProps) {
  return <input type="email" {...props} />
}
export function EmailInput(props) {
  return (
    <Card>
      <Labeled text={'Email address'}>
        <EmailInput_base {...props} />
      </Labeled>
    </Card>
  )
}

Why react-decor

Composition without the pyramid

Wrappers move out of the tree and onto declarations — what remains is flat, scannable, and enforced by tooling.

Zero runtime

Decorators are erased at build time into plain nested JSX. No library code ships — the output is exactly the hand-written equivalent.

Toolchain green

at-tsc, the tsserver plugin, and the ESLint parser all see valid TypeScript — hover, go-to-def, and completions keep working inside decorators.

Lint-enforced conventions

Eight react-decor rules turn the composition conventions into errors: Slot boundaries, props channels, local atoms, pass-through layers, and more.

Readable sections

One wrapper per atom, keys at the callsite, zero-prop composition — sections read top-down instead of inside-out.

This grid is compiled from — view its source
why-strip.at.tsx
@StaggerItem({ className: 'border-border bg-card/60 flex h-full flex-col gap-3 rounded-xl border p-5' })
const FeatureTile = ({ item }: { item: (typeof FEATURES)[number] }) => {
  const { t } = useTranslation()
  const Icon = item.icon
  return (
    <>
      <div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-lg">
        <Icon className="size-5" aria-hidden="true" />
      </div>
      <h3 className="text-base font-semibold tracking-tight">
        {t(`home.why.items.${item.key}.title`)}
      </h3>
      <p className="text-muted-foreground text-sm">{t(`home.why.items.${item.key}.body`)}</p>
    </>
  )
}

@Stagger({ className: 'mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4' })
const WhyStripBody = () => FEATURES.map((item) => <FeatureTile key={item.key} item={item} />)

Before / after

The same section, without the nesting

A real stats band: every wrapper as a nested element on the left; the identical section authored with react-decor on the right.

Nested wrappers

stats-band.tsx
// Every wrapper nests the next — the leaf is buried four levels deep,
// and the section reads inside-out.
export function StatsBand() {
  const { t } = useTranslation()
  return (
    <SectionShell data-section="stats" className="border-y">
      <Region label={t('stats.region')}>
        <Stagger className="grid gap-8 sm:grid-cols-4">
          {STATS.map((stat) => (
            <StaggerItem key={stat.key} className="flex flex-col gap-2">
              <StatValue stat={stat} />
            </StaggerItem>
          ))}
        </Stagger>
      </Region>
    </SectionShell>
  )
}

Decorated declarations

stats-band.at.tsx
// Identical output. Wrappers ride declarations; the key rides the map's
// callsite; the one hook-reading wrapper rides the return.
@StaggerItem({ className: 'flex flex-col gap-2' })
const StatTile = ({ stat }: { stat: Stat }) => <StatValue stat={stat} />

@Stagger({ className: 'grid gap-8 sm:grid-cols-4' })
const StatsBandList = () => STATS.map((stat) => <StatTile key={stat.key} stat={stat} />)

@SectionShell({ 'data-section': 'stats', className: 'border-y' })
export function StatsBand() {
  const { t } = useTranslation()
  @(<Region label={t('stats.region')} />)
  return <StatsBandList />
}

The decorated column compiles into exactly the markup of the nested column — the decorators are erased at build time.

Quick start

Four files and you're green

react-decor rides your existing Vite + TypeScript + ESLint setup — one plugin per tool, scoped to *.at.tsx files.

Vite plugin

The transform runs enforce:'pre' and only touches *.at.tsx files. Import it by its bare specifier — the whole chain is plain CommonJS.

vite.config.ts
// BARE specifier by design — the chain is plain CommonJS;
// a relative import would get bundled into the config.
import reactDecor from 'react-decor/vite'

export default defineConfig({
  plugins: [reactDecor(), reactRouter(), tailwindcss()],
})

tsserver plugin

The editor shows your raw text while the language service type-checks the virtualized code. Select the workspace TypeScript once.

tsconfig.app.json
// The editor shows raw text; the language service sees
// valid code (use the workspace TypeScript).
"plugins": [
  { "name": "@react-router/dev" },
  { "name": "react-decor-ts-plugin" }
]

ESLint helper

reactDecorEslint() scopes the virtualizing parser and the full rule set to *.at.tsx — while no-key-prop and no-t-prop guard every React file.

eslint.config.js
// Virtualizing parser + full rule set on *.at.tsx;
// no-key-prop / no-t-prop apply to every React file.
import { reactDecorEslint } from 'react-decor/eslint'

export default tseslint.config(
  /* …type-aware blocks… */
  reactDecorEslint(),
  prettier,
)

at-tsc + Prettier

at-tsc wraps the compiler for CLI type-checks, with code frames from your raw text. Prettier can't parse the files — ignore them and hand-format.

package.json
// package.json — at-tsc wraps tsc with raw-text code frames
"typecheck": "react-router typegen && at-tsc -p tsconfig.app.json --noEmit"

// .prettierignore — no parse possible, hand-format these
*.at.tsx

Conventions

The patterns the docs teach

Three load-bearing conventions carry most of the value — each linked to its full treatment in the reference.

Ship the JSX, skip the pyramid

Read the full reference — grammar, placements, conventions, toolchain shims, and the complete error catalog.