Back to Ghost

Shade — `inputSurface` recipe

.agents/skills/shade-input-surface-recipe/SKILL.md

6.50.03.9 KB
Original Source

Shade — inputSurface recipe

Form controls share one visual rule: border, background, radius, transition, focus ring, invalid state. That rule lives in inputSurface (apps/shade/src/components/ui/input-surface.ts) and is used by Input, Textarea, InputGroup, and the Select trigger. Don't duplicate the chrome — compose the recipe.

ts
import {inputSurface, inputSurfaceClasses} from '@/components/ui/input-surface';
import {cn} from '@/lib/utils';

Two common modes

inputSurface('self') — directly on the focusable element

Use on <input>, <textarea>, or any element that itself receives focus.

tsx
<input
    className={cn(
        inputSurface('self'),
        'flex h-9 w-full px-3 py-1 text-control placeholder:text-muted-foreground',
        className
    )}
    {...props}
/>

Covers: base chrome + focus-visible: ring + aria-[invalid=true] styling + disabled: opacity.

inputSurface('within') — on a wrapper containing a focusable child

Use when the styled element is the wrapper (e.g. InputGroup) and focus state should react to any focusable descendant via :has().

tsx
<div className={cn(
    inputSurface('within'),
    'flex h-9 items-center gap-2 px-3',
    className
)}>
    <Icon />
    <input className="bg-transparent outline-hidden focus:outline-hidden" />
</div>

Covers: base chrome + has-[:focus-visible]: ring + has-[[aria-invalid=true]]: styling. (No disabled atom — wrappers don't get a disabled HTML attribute.)

Edge case — only one specific child triggers focus state

When 'within' is too broad (e.g. a wrapper with multiple focusables but only one should drive chrome), compose atoms manually so Tailwind's JIT can statically detect the class string:

tsx
import {inputSurfaceClasses} from '@/components/ui/input-surface';

<div className={cn(
    inputSurfaceClasses.base,
    inputSurfaceClasses.invalidWithin,
    // Literal class stringTailwind needs to see it as text
    'has-[[data-slot=control]:focus-visible]:border-focus-ring',
    'has-[[data-slot=control]:focus-visible]:ring-2',
    'has-[[data-slot=control]:focus-visible]:ring-focus-ring/25'
)} />

Available atoms:

  • inputSurfaceClasses.base — border, background, radius, transition
  • inputSurfaceClasses.focusSelf — focus chrome for self mode
  • inputSurfaceClasses.focusWithin — focus chrome for within mode
  • inputSurfaceClasses.invalidSelfaria-[invalid=true] styling on self
  • inputSurfaceClasses.invalidWithinaria-[invalid=true] styling on a descendant
  • inputSurfaceClasses.disabledSelf — disabled styling for self mode

What the recipe owns vs. what you add

Recipe ownsYou add
Border (border-control-border)Height, padding
Background (bg-surface-elevated, dark:bg-transparent)Typography (text-control, text-sm)
Radius (rounded-md)Layout (flex, items-center)
Transition (transition-colors)Placeholder styling
Focus ring (focus-visible:ring-focus-ring/25)Component-specific tweaks
Invalid state (aria-[invalid=true]:border-destructive)Icons / slot positioning
Disabled (disabled:opacity-50) — self only

Don't

tsx
// BAD — rolling your own focus chrome on a form control
<input className="
    rounded-md border border-input bg-background
    focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
    aria-[invalid=true]:border-red-500
    disabled:opacity-50
" />

// BAD — using inputSurface('within') on a self-focused element (over-broad)
<input className={cn(inputSurface('within'), 'h-9')} />

// BAD — non-literal class concatenation that Tailwind JIT can't see
const dyn = `has-[[data-slot=control]:focus-visible]:${ringColor}`;

Source of truth

apps/shade/src/components/ui/input-surface.ts — the JSDoc on inputSurface is canonical. Human docs: Storybook → Recipes / inputSurface.