Skip to content
Back to skills

Rcr Frontend

ASecurity

Component and styling conventions for Red Cliff Record. Use when writing, reviewing, or refactoring any frontend code under `src/app/` — components, routes, styles, forms, icons, animations. Triggers on `.tsx`/`.css` edits in this project, design tokens, Panda CSS recipes, Base UI primitives, TanStack Form/Router, Lucide icons, color palettes (`palette`, `chromatic`, `mode`), or questions like "how do I style X in RCR". Also invoke proactively before UI work to ensure Panda-first styling and ...

  • 9 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added February 8, 2026
developmentgoreactdebuggingrefactoringapifrontend

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 6, 2026

npx -y skills add Aias/red-cliff-record --skill rcr-frontend --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Rcr Frontend?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Rcr Frontend
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/aias-rcr-frontend/badge)](https://www.skillsdirectory.com/skills/aias-rcr-frontend)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: rcr-frontend
description: Component and styling conventions for Red Cliff Record. Use when writing, reviewing, or refactoring any frontend code under `src/app/` — components, routes, styles, forms, icons, animations. Triggers on `.tsx`/`.css` edits in this project, design tokens, Panda CSS recipes, Base UI primitives, TanStack Form/Router, Lucide icons, color palettes (`palette`, `chromatic`, `mode`), or questions like "how do I style X in RCR". Also invoke proactively before UI work to ensure Panda-first styling and Base UI primitives, and to clean up legacy Tailwind/Radix in any file you touch.
---

# RCR Frontend

Supplements global `react-best-practices` and `code-quality` skills.

## Source of truth

- **Panda CSS is canonical.** All new frontend code uses Panda style props, tokens, and recipes.
- **Base UI is canonical.** Headless primitives come from `@base-ui/react`, not `radix-ui` or `@radix-ui/*`.
- **Tailwind and Radix are legacy**, present only because the migration is in progress. Any file you touch should be cleaned up:
  - Replace Tailwind class strings (`className="..."`, `cn(...)`, `c-*` color aliases) with Panda style objects.
  - Replace `radix-ui` primitives with `@base-ui/react` equivalents.
  - Remove now-unused imports (`cn`, `css` from `@/styled-system/css` if no longer needed, `radix-ui`, `lucide-react` sizing props).
- Do not introduce new Tailwind utilities or Radix imports, even as a bridge. If Panda lacks something you need, ask — don't fall back to Tailwind.
- When translating legacy Tailwind to Panda, see `references/tailwind-to-panda.md` for the token mapping tables (colors, radius, sizes), idiom translations, and the bracket-literal decision tree.

## Component organization

- Reusable components: `src/app/components/` (kebab-case).
- **Simple component** → single file: `src/app/components/badge.tsx`.
- **Component with a recipe** → folder with colocated recipe: `src/app/components/button/{index.tsx, button.recipe.ts}`.
- **Multi-slot component** → folder with slot recipe and primitives: `src/app/components/scroll-area/{scroll-area.recipe.ts, scroll-area.tsx, index.ts}` (Base UI + `createSlotRecipeContext`) or `alert-dialog/` (legacy Radix, pending migration).
- **Page-specific components** → `-components/` folder adjacent to the route or `-component.tsx` suffix (TanStack Router convention for excluded directories).
- Register new recipes in `panda.config.ts` (`theme.extend.recipes` for single, `theme.extend.slotRecipes` for slot) and run `bun run stylegen` so `src/app/styled-system/recipes` updates.

## Styling API — preference order

1. **`css` prop on `styled()` components** — `<Button css={{ marginBlockStart: '4' }}>`. Preferred wherever available.
2. **`styled.*` elements** — `<styled.div css={{ display: 'flex' }}>` for HTML elements that need styles.
3. **`css()` function** — `className={css({ ... })}` when working with an element that doesn't expose `css` (rare, e.g. third-party components).
4. **`cx()`** — only to merge class names. Don't use `cn()` / `tailwind-merge` in new code; that utility is scoped to the remaining Tailwind surface.

`jsxStyleProps: 'minimal'` is set in `panda.config.ts`, so individual style props (`<styled.div bg="red">`) are **not** available — always use the `css` prop.

**Prop types come from Panda, not React.** When typing a design-system component that composes Panda-styled primitives (`styled()`, `withContext`, recipe-bound), import `ComponentProps` / `ComponentPropsWithRef` from `@/styled-system/types`, not from `react` — the Panda types include the `css` prop, recipe variants, and `unstyled`. React's `ComponentProps` is fine for non-Panda components elsewhere in the app.

### Composing style objects — `css.raw()`

Use `css.raw()` to merge style objects for the **`css` prop**. Don't spread one object into another (`{ ...base, ...specific }`) — Panda's static extractor can't reliably analyze spreads.

- **Reusable chunks** → name a module-scope `css.raw({ ... })` const.
- **One-off composition** → `css={css.raw(chunkA, chunkB, { ...local })}`.
- **Consumer overrides** → last argument: `css={css.raw(baseStyles, cssProp)}`.
- **`css()` vs `css.raw()`** — `css()` emits a **class name** (`className={css({ ... })}` on elements without a `css` prop, e.g. third-party hosts). `css.raw()` returns a **style object** for the `css` prop only; don't assign its result to `className`.

```tsx
const revealOnGroupCss = css.raw({
  opacity: 0,
  _groupHover: { opacity: 1 },
  _groupFocusWithin: { opacity: 1 },
});

<styled.div
  className="group"
  css={css.raw(revealOnGroupCss, { position: 'absolute', inset: '0' })}
/>;

export function Toolbar({ css: cssProp }: { css?: SystemStyleObject }) {
  return <styled.div css={css.raw({ display: 'flex', gap: '2' }, cssProp)} />;
}
```

## Panda fundamentals

- **`strictTokens: true`** and **`strictPropertyValues: true`** — every value must be a defined token, a keyword or free-form value the property's generated type accepts, or a bracket-escaped literal. If TS errors on a color/spacing value, either use a token or confirm a bracket literal is truly unavoidable.
- **No shorthands.** `shorthands: false` in the config. Write `marginBlockStart`, `paddingInline`, `backgroundColor`, `flexDirection` — not `mt`, `px`, `bg`, `flexDir`.
- **Logical properties** over physical: `inlineStart`/`inlineEnd`/`blockStart`/`blockEnd`, `paddingInline`/`paddingBlock`, `borderInlineStart`. Reserve `left`/`right`/`top`/`bottom` for actual positioning semantics (e.g. `position: 'absolute'` with `insetBlockStart`).
- **`boxSize` when width and height match.** Same rule for `paddingInline`/`paddingBlock` over `paddingLeft` + `paddingRight`.
- **Declaration order** follows intent (outside-in): position/display → flex/grid container → flex/grid child → sizing/spacing → overflow → typography → visual → transforms/animation → interaction. Not alphabetical.
- **Static analysis.** Panda needs to parse style objects at build time — no dynamic values or runtime-computed keys in `css` objects. For dynamic numbers, use an inline `style` attribute; for dynamic variants, use `data-*` attributes and target them via selectors.
- **`[]` escape hatch** is for values the property's type rejects — other units or computed values on token-typed properties (`boxSize: '[1.15em]'`, `width: '[calc(100% - 2rem)]'`). Brackets change only the generated class name, never the emitted declaration, so a value that typechecks bare goes bare. These do: CSS-wide keywords (`inherit`), `auto` on sizes, `transparent` and `currentColor` on colors, any `var(--…)` value, property names in `transitionProperty`, and any string on open-string properties (`gridTemplateColumns`, `backgroundImage`, `backgroundSize`). `fit-content` and `max-content` still need brackets on `width` and `minHeight`. If an exact token exists, use it. When a measurement falls _between_ scale steps — say a mock specifies `0.4rem` of gap while the spacing scale offers `1.5` (0.375rem) and `2` (0.5rem) — round to the nearest token instead of bracket-escaping the exact value; staying on the scale keeps the spacing rhythm consistent and beats a pixel-perfect literal. Reserve brackets for values with no scale neighbor at all. Use real spaces inside brackets — not underscores.
- **Token interpolation in compound values**: `border: '1px solid {colors.divider}'` (curly syntax), not `token(colors.divider)` unless providing a fallback.
- **Regenerate after config changes.** After editing recipes, tokens, conditions, or `panda.config.ts`, run `bun run stylegen` (`panda build --clean --max-warnings 0`) so `src/app/styled-system/*` is in sync. It fails on any compiler warning, because each warning marks a style that emits no CSS (an unknown condition, a misplaced nested property).

## Tokens

Everything lives under `src/app/styles/`:

| File                                                       | Contents                                                          |
| ---------------------------------------------------------- | ----------------------------------------------------------------- |
| `colors.ts`                                                | Color engine: Radix hue ramps, palettes, semantic formulas        |
| `typography.ts`                                            | `textStyle` values, font families, sizes, weights                 |
| `dimensions.ts`                                            | `spacing` and `sizes` (multiples of `0.25rem`, plus fractions)    |
| `borders.ts` / `radii.ts` / `shadows.ts` / `animations.ts` | Their namesakes                                                   |
| `conditions.ts`                                            | Custom Panda conditions                                           |
| `plugins.ts`                                               | Custom utilities (`palette`, `chromatic`, `mode`, `animateIn`, …) |

### Semantic color tokens (use these, not Radix scales directly)

`display`, `primary`, `secondary`, `muted`, `symbol`, `accent`, `accentActive`, `background`, `surface`, `container`, `float`, `divider`, `border`, `edge`, `focus`, `mist`, `splash`, `flood`, `main`, `mainActive`, `mainContrast`.

Use them as token names in Panda (`color: 'primary'`, `backgroundColor: 'float'`). The `c-*` names (`bg-c-paper`, `text-c-primary`) are the Tailwind aliases — only relevant while removing Tailwind from legacy components. In Panda code, `c-paper` doesn't exist; the equivalent is `float` or `surface` depending on intent (check existing usage).

### Theme boundaries via `palette`, `chromatic`, and `mode`

Three inherited axes steer every semantic color token. Set any of them anywhere in the tree and the whole subtree re-resolves against the new context until a descendant sets that axis again.

| Utility     | Values                                                      | Effect                                                                                                                       |
| ----------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `palette`   | `artifact`, `entity`, `concept`, `error`, `success`, `info` | Selects the hue ramps (chromatic + associated neutral) that semantic tokens like `accent`, `main`, `splash` resolve through. |
| `chromatic` | `true`, `false`                                             | Switches every semantic token between its chromatic and neutral variant.                                                     |
| `mode`      | `dark`, `light`, `inverted`, `normal`                       | Sets `color-scheme`; all colors are `light-dark()` pairs, so the subtree flips instantly.                                    |

```tsx
<styled.span css={{ palette: 'error', chromatic: true, backgroundColor: 'splash', color: 'accent' }}>
  Error
</styled.span>

<styled.section css={{ palette: 'info', chromatic: true }}>
  {/* Everything inside uses the chromatic variant of the `info` palette */}
</styled.section>
```

They work identically inside recipes — e.g. `_invalid: { palette: 'error', chromatic: true }` on input states. Each boundary also re-asserts `color: primary` at base-layer precedence, so plain inherited text adapts across the boundary while any explicit `color` still wins.

### Text styles

Use `textStyle` tokens (`xs`, `sm`, `base`, `lg`, `xl`, `2xl`, ..., `9xl`) instead of pairing `fontSize` + `lineHeight` manually. They match Tailwind v4's `text-*` scale by design, so map `text-{size}` → `textStyle: '{size}'` **first**, then override `fontSize` / `lineHeight` / `fontWeight` **only** where the original diverges from that textStyle's bundled values. Never set a bare `fontSize` when a matching textStyle exists.

```tsx
<styled.h2 css={{ textStyle: 'xl', fontWeight: 'medium' }}>Section</styled.h2>
```

So `text-lg leading-none font-semibold` → `{ textStyle: 'lg', lineHeight: 'none', fontWeight: 'semibold' }` (textStyle for the size, then the two divergences), and a bare `text-sm` → `{ textStyle: 'sm' }`. Don't avoid `textStyle` just because you need to override its line-height — set it and override.

Headings `h1`–`h6` already get default text styles from `src/app/styles/globals.ts`; only override when the default is wrong for the context.

### Spacing and sizes

Numeric tokens are `0.25rem` multiples: `0, 0.25, 0.5, 0.75, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80, 96`. Plus fractions (`1/5`, `1/4`, `1/3`, `1/2`, `2/3`, `3/4`, `4/5`, `full`), `em`, and `px`.

## Custom conditions and utilities

| Condition                                               | Selector                                        | Use for                                                                                                                                                                     |
| ------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `_dark`                                                 | `:where([data-color-scheme="dark"], .dark) &`   | Color-scheme-scoped overrides                                                                                                                                               |
| `_light`                                                | `:where([data-color-scheme="light"], .light) &` | Color-scheme-scoped overrides                                                                                                                                               |
| `_childIcon`                                            | `& :where(svg, .icon, .lucide)`                 | Size/color icons inside a container. Prefer over `& svg`.                                                                                                                   |
| `_sideBottom` / `_sideTop` / `_sideLeft` / `_sideRight` | `&[data-side=<side>]`                           | Radix/popper-positioned content (popover, dropdown, hover-card) — directional slide-in animation per placement. Use these instead of raw `'&[data-side=bottom]'` selectors. |

Reach for a defined condition over a raw `[data-*]` selector — but **first check Panda's built-ins so you don't duplicate one**: `_vertical` / `_horizontal` (`[data-orientation]`), `_open` / `_closed` (`[data-state]`), `_hover`, `_focusVisible`, `_disabled`, `_invalid`, `_placeholder`, and the `_group*` / `_peer*` families all ship out of the box. Only when Panda has nothing (e.g. the project's `_side*`, `_childIcon`) add it to `styles/conditions.ts` (and `bun run stylegen`) rather than inlining the attribute selector in a recipe.

Useful utilities (full list in `src/app/styles/plugins.ts`):

- `animateIn` / `animateOut` + modifiers `fadeIn`, `zoomIn`, `slideInX`, `slideInY` (and `*Out` variants) — compose enter/exit animations with the `enter`/`exit` keyframes.
- `translateCenter: 'x' | 'y' | 'xy'` — short for `translate: '-50% ...'`. Use for centered overlays **instead of** a raw `translate: '[-50% 0]'` / `'[0 -50%]'` / `'[-50% -50%]'` literal. (A centering offset combined with an extra nudge — e.g. `translate: '[0 calc(-50% - 2px)]'` for an arrow — still needs a bracket literal; `translateCenter` only covers the pure ±50% cases.)
- `mode: 'normal' | 'inverted' | 'light' | 'dark'` — flip or pin color scheme. `mode: 'inverted'` is the idiomatic way to punch out a dark island on a light page.
- `debug: true` — visual outline for layout debugging; remove before committing.

## Recipes

### Single-element — `defineRecipe`

```ts
import { defineRecipe } from '@/app/styles/define-recipe';

export const tagRecipe = defineRecipe({
  className: 'tag',
  base: { display: 'inline-flex', paddingInline: '2', borderRadius: 'sm' },
  variants: {
    variant: { solid: { ... }, soft: { ... } },
    size: { default: { height: '6' }, sm: { height: '5' } },
  },
  defaultVariants: { variant: 'solid', size: 'default' },
});
```

Always use `defineRecipe` / `defineSlotRecipe` from `@/app/styles/define-recipe` (not from `@pandacss/dev`). Those wrappers apply Panda's generated strict types so invalid props/values error at author time.

Bind single-element recipes with `styled()` from `@/styled-system/jsx`:

```tsx
import { Input as BaseInput } from '@base-ui/react/input';
import { styled } from '@/styled-system/jsx';
import { input } from '@/styled-system/recipes';
import type { ComponentProps } from '@/styled-system/types';

export const Input = styled(BaseInput, input);
export type InputProps = ComponentProps<typeof Input>;
```

Same pattern for native elements (`styled('label', label)`) and non-Base UI hosts (`styled(Link, button)`). See `input/`, `button/`, `badge/`, `label/`, `separator/`.

### Multi-slot — `defineSlotRecipe` + `createSlotRecipeContext`

Registered slot recipes bind via `createSlotRecipeContext(slotRecipe)` → `withProvider` (root) + `withContext` (child slots). Canonical Base UI examples: `scroll-area/`, `tooltip/`. Legacy Radix still on `alert-dialog/` — migrate to Base UI when touched.

```tsx
import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area';
import { createSlotRecipeContext } from '@/styled-system/jsx';
import { scrollArea } from '@/styled-system/recipes';
import type { ComponentProps } from '@/styled-system/types';

const { withProvider, withContext } = createSlotRecipeContext(scrollArea);

const Root = withProvider(BaseScrollArea.Root, 'root');
const Viewport = withContext(BaseScrollArea.Viewport, 'viewport');
const Content = withContext(BaseScrollArea.Content, 'content');
const ScrollBar = withContext(BaseScrollArea.Scrollbar, 'scrollbar');
const Corner = withContext(BaseScrollArea.Corner, 'corner');
const Thumb = withContext(BaseScrollArea.Thumb, 'thumb');

export const ScrollArea = ({
  children,
  orientation = 'vertical',
  ...props
}: ComponentProps<typeof Root>) => (
  <Root orientation={orientation} {...props}>
    <Viewport>
      <Content>{children}</Content>
    </Viewport>
    <ScrollBar orientation={orientation}>
      <Thumb />
    </ScrollBar>
    <Corner />
  </Root>
);
```

### Slot-component file conventions

Models: `scroll-area/` and `tooltip/` (Base UI + slot recipe), `alert-dialog/` (Radix, pending migration).

- **Import naming.** Base UI namespace imports use `Base*` — `import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area'`. Never `*Primitive`; that suffix is Radix/Shadcn (`ScrollAreaPrimitive`, `AlertDialogPrimitive`).
- **Slots** = every part the headless primitive exposes in its documented anatomy, plus `root`. Don't invent slots the primitive never had; don't omit parts it requires in the tree (Base UI `ScrollArea.Content` inside `Viewport` is mandatory — Radix hid an equivalent wrapper inside `Viewport`). `root` is mandatory — bind with `withProvider`, or `withRootProvider` when the root renders no DOM (Radix context-only roots during migration).
- **Structural slots.** Register and bind every anatomy part with `withContext` even when the recipe adds no styles — use `slotName: {}` in `base` (see `scroll-area` `content` and `corner`). The slot still gets a generated class and accepts `css` overrides at call sites; the primitive may apply its own inline defaults (e.g. Base UI `Content`'s `minWidth: fit-content`).
- **Recipe-only variants.** Variants that style slots but aren't props on the headless root go on the `withProvider` root — `createSlotRecipeContext` consumes them for styling without forwarding to the primitive. Example: `orientation` on `ScrollArea`'s `Root` drives scrollbar slot styles; Base UI takes `orientation` on `Scrollbar`, not `Root`.
- **Bind every rendered slot** with `withContext`, and **compose with the bound slots** — a `Content` that needs a portal renders `<Portal>`, never the raw Base UI portal outside styled wrappers.
- **Portal is internal by default.** A composed `Content` includes the `Portal` (portal-by-default, like Base UI), so bind `Portal` as a non-exported `const` and render it inside `Content`. Don't export it — an exported `Portal` lets a consumer double-wrap (`<X.Portal><X.Content/></X.Portal>`) into nested portals. Export `Portal` only for the manual-composition pattern where `Content` is a bare slot and the consumer writes `<Portal><Overlay/><Content/></Portal>` themselves (e.g. `alert-dialog`).
- **Composed vs part exports.** When the app always uses the full tree, export one composed component (`ScrollArea`). When consumers assemble parts (`Tooltip.Root`, `Tooltip.Trigger`, `Tooltip.Content`), export the bound parts. Keep internal styled slots (`StyledPositioner`, `StyledPopup`) unexported when a composed wrapper owns them.
- **Target slots by class, not `data-slot`.** Reach a subcomponent through its generated `.<recipe>__<slot>` class (e.g. `.scroll-area__viewport`). `createSlotRecipeContext` stamps every bound slot with `data-slot="<slot>"`, overwriting any value a caller passes, and slot names like `root` and `content` repeat across recipes.
- **`unstyled` prop** drops a slot's recipe styles so you can restyle it via `css` in a specific composition.
- **Shared style chunks** → a module-scope `css.raw({ ... })` const, composed at each slot with `css.raw(chunk, { ...slotSpecific })`. Never object-spread (`{ ...chunk, ...local }`) or `as const`.
- **One slot for identical-styled primitives.** If two primitives carry identical, never-diverging slot styles (e.g. a select's scroll-up vs scroll-down buttons), bind both to a single slot (`withContext(ScrollUpButton, 'scrollButton')` + `withContext(ScrollDownButton, 'scrollButton')`) rather than defining two slots that share a copied chunk. Duplicating a chunk across slots that are never independently overridden is a smell — collapse to one slot; split only when they genuinely differ.
- **Naming:** a slot wrapped by a composed component is the internal `Styled<Slot>` (not exported); its public composed function takes the plain name. Directly-used slots are plain exported consts. Keep it uniform within the file.

### When to reach for a recipe

- Reused across the app → recipe in the component folder, registered in `panda.config.ts`.
- One-off page-local multi-slot component → inline `sva()` from `@/styled-system/css`, optionally with `createSlotRecipeContext`.
- One-off single-element → `css` prop on a `styled.*` element. No recipe needed.

## Base UI primitives

- Import from `@base-ui/react/<component>` (e.g. `@base-ui/react/button`, `@base-ui/react/scroll-area`) or the barrel `from '@base-ui/react'`.
- **Namespace alias is `Base*`, not `*Primitive`.** `import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'`. Reserve `*Primitive` for legacy Radix imports only.
- **Base UI uses `render={<Element />}` for composition** — the equivalent of Radix's `asChild`. Pass an element instance, not `asChild` + children.

  ```tsx
  <Button variant="soft" render={<Link to="/records">Records</Link>} />
  ```

- When migrating a Radix component, read the Base UI docs for the equivalent anatomy — don't assume API parity. Common differences:
  - Radix `Content` ≈ Base UI `Popup` (dialog, tooltip, …).
  - Base UI may expose parts Radix hid internally (e.g. `ScrollArea.Content` inside `Viewport`; Radix injected an inner wrapper automatically).
  - Part names shorten under the namespace (`Scrollbar`, `Thumb`, `Corner` — not `ScrollAreaScrollbar`).
- A few utility primitives still live in `radix-ui` today (`@radix-ui/react-slot` for `Slot`, `@radix-ui/colors` for the color scales). Keep those; the migration target is the interactive primitives.

## Icons

- Lucide icons, imported with the `Icon` suffix: `import { HomeIcon } from 'lucide-react'` — not `Home`.
- **Never set the `size` prop on a Lucide icon, and almost never style an icon directly.** An icon should be styled by its PARENT via `_childIcon` — it should not carry its own `className`/`css`. Control size/color via:
  - `_childIcon` on the parent container, `styled()` component, or recipe (`_childIcon: { boxSize: '4' }`) — the default, strongly preferred.
  - A direct `css={{ boxSize: '4' }}` on the icon ONLY as a last resort, when there is genuinely no parent-based way to target it.
- Icons inherit `color: currentColor` and `boxSize: '1em'` from `globals.ts` by default — they scale with surrounding text. Only override when you need something different.
- Use `<Spinner />` from `@/components/spinner` for loading states.

## Forms

- TanStack Form (`@tanstack/react-form`) + Zod v4 for validation. Client hooks via `@/app/trpc.ts`.
- Co-locate the Zod schema with the form component; derive types from the schema.
- The tRPC client's toast link handles mutation errors globally. **Don't add `toast.error(...)` in `onError`** — it's already wired up.
- Remember React Compiler is on — don't manually `useMemo`/`useCallback` field renderers for perf.

## Animations

- **Prefer CSS/Panda animations** via `animateIn`/`animateOut` + modifiers and the `enter`/`exit` keyframes. See `dialog.tsx` and `hover-card.tsx` for entry/exit patterns. The modifier variables are registered without inheritance (`animationVars` in `animations.ts`), so set modifiers on the animating element itself, never on an ancestor.
- **`motion/react`** (the Framer Motion successor) for genuinely complex cases — `AnimatePresence`, layout animations, gesture/drag. Import from `motion/react`, not `framer-motion`.
- Animations must be purposeful and GPU-safe (`transform`, `opacity`). Respect reduced-motion preferences.

### Easing and duration tokens

Easing curves follow Emil Kowalski's "Easing Blueprint" and are registered as Panda tokens. Within each family, intensity goes `quad → cubic → quart → quint → expo → circ` (weakest to strongest acceleration).

| Family      | Tokens                                                           | Use for                                                                                                                           |
| ----------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `easeOut`   | `easeOut.quad`, `.cubic`, `.quart`, `.quint`, `.expo`, `.circ`   | Elements entering or exiting the viewport (dropdowns, modals, toasts). Fast start feels responsive; gentle landing feels natural. |
| `easeInOut` | `easeInOut.quad`, `.cubic`, `.quart`, `.quint`, `.expo`, `.circ` | Elements already on screen that move or morph in place (toggles, expanding panels, sidebars).                                     |
| `linear`    | `linear`                                                         | Constant-rate loops like spinners.                                                                                                |

Typical defaults: `easeOut.cubic` for medium overlays (popovers, dialogs), `easeOut.quad` for small UI (tooltips, switches), `easeOut.quint` for large motion (drawers, sidebars). Existing recipes (`alert-dialog.recipe.ts`, `textarea.recipe.ts`) show the pattern.

**Never use `ease-in` as a family.** It feels sluggish as an entrance — if you reach for it, you almost always want `easeInOut` instead.

Durations are raw ms tokens: `durations.0`, `50`, `100`, `150`, `200`, `250`, `300`, `350`, `400`, `500`. Pick a cadence that scales with element size — ~150ms for tooltips, ~250ms for popovers/dialogs, ~350ms for drawers.

**Never use the `transition` shorthand.** Always spell out the three longhands so each value is explicit and tokenized:

```ts
transitionProperty: 'opacity, transform',
transitionDuration: '250',
transitionTimingFunction: 'easeOut.cubic',
```

`transitionProperty` takes property names bare (`'opacity'`, `'color, box-shadow'`) as well as the predefined groups `common`, `colors`, `size`, `position`, `background`. Name exactly the properties that change rather than rounding up to a group. `transitionDuration` and `transitionTimingFunction` take tokens (no escape). When a Tailwind `transition-*` class carries no explicit duration/easing, default to `'150'` + `'easeOut.cubic'` unless the element's size/role calls for another curve.

Curves, tokens, and the helpers that build them (`easingCurves`, `curveToCSS`, `getEasing`) all live in `src/app/styles/animations.ts`.

## Event handlers

- No inline arrow functions in JSX `onClick` / `onChange` / etc. Extract to a named handler or a child component with its own handler. This keeps renders cheap and diffs readable.

## Commands

- `bun check` — lint + typecheck + format (runs `oxlint`, `tsgo`, `oxfmt`). Run after every non-trivial change.
- `bun run stylegen` — regenerate `src/app/styled-system/*` after touching recipes, tokens, or `panda.config.ts`. Fails on any Panda compiler warning.
- `bun run dev` runs both `panda --watch` and Vite; the user starts the dev server manually — don't launch it.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.