@place/design
A curated component library shipped with the platform — Button, Field / Input / Textarea, Dialog, Toast, Tooltip, Menu, Avatar, Badge, Card. Native-first composition is a charter principle: every primitive sits on a real browser primitive (<dialog>, the Popover API, :user-invalid, @starting-style) so the framework adds behavior, not infrastructure.
@place/design is a curated package built on top of the existing systems — recipe(), themeTokens(), the component runtime. The platform map keeps nine systems; the design library is one of the curated packages on top. See ADR 0016 for what we deliberately avoid (shadcn copy-paste, Radix asChild, runtime CSS-in-JS, tailwind-merge as runtime patch).Customization
Every component is highly customizable on four axes — without forking the source:
- Theme tokens. Components reference theme tokens (
bg-card,text-fg,bg-accent, …) which resolve to your theme's CSS variables. Swap the theme and every component re-skins atomically. See Theming. - Typed recipe variants. Each component's public surface IS its variant ladder —
intent,size,side, etc. The variants ARE the override channel; charter non-negotiable #4 (noclassName-as-override). - Two-channel additive contract. Every component accepts
class(additive on the root). Multi-part components additionally acceptclassNames={{ ...parts }}— a typed map for targeted sub-part overrides. Combobox'sclassNames={{ popover, option, leftIcon, ... }}, Dialog'sclassNames={{ backdrop }}, CodeBlock'sclassNames={{ header, pre, line }}. The part keys are typed — unknown keys are compile errors, no silent ignores.rootis not a valid key — useclassfor the root (one spelling per concept). - Render slots. Components with structural variability (Combobox, CodeBlock, Toast) expose render-function props (
renderOption,renderEmpty,headerSlot) so consumers replace per-item content without rebuilding the surrounding behavior (keyboard nav, popover wiring, a11y attributes).
What we deliberately don't have: Radix-style asChild polymorphism (NN#2 — typed slot props instead) and the copy-paste-shadcn model (NN#1 — components are imported, not pasted). See ADR 0016 for the rationale.
Install
// Already wired in workspace apps via workspace:* dep.
// In external apps:
// bun add @place/design
//
// import { Button, Card, Field, Dialog, ... } from '@place/design'
Wire the library's styles
The library ships a small Tailwind input file for things utility classes can't express (currently the Dialog's @starting-style transitions). Pass it to app()'s styles option — a string array, one entry per layer:
// Wire the design library's Tailwind input (Dialog @starting-style
// transitions, etc.) into your app's styles. `styles` takes a string
// array — each entry is a layer, concatenated in order.
import { styles as designStyles } from '@place/design'
import { styles as appStyles } from './styles.ts'
app({
pages: [...],
styles: [designStyles, appStyles],
}).run()
Button
import { Button } from '@place/design'
<Button intent="primary" size="md">Save</Button>
<Button intent="ghost" size="sm">Cancel</Button>
<Button intent="destructive" loading={isDeleting()}>
Delete account
</Button>
Field / Input / Textarea
import { Field, Input } from '@place/design'
<Field label="Email" hint="We'll never share it." error={emailError()}>
<Input
type="email"
name="email"
required
value={email()}
onInput={(v) => email.set(v)}
/>
</Field>
// :user-invalid styling kicks in only AFTER the user interacts —
// no red borders on every empty field as the page loads.
Dialog
import { Dialog, Button } from '@place/design'
import { state } from '@place/reactivity'
const open = state(false)
<Button onClick={() => open.set(true)}>Open dialog</Button>
<Dialog open={open} onClose={() => open.set(false)}>
<Dialog.Header>Are you sure?</Dialog.Header>
<Dialog.Body>This action can't be undone.</Dialog.Body>
<Dialog.Footer>
<Button intent="ghost" onClick={() => open.set(false)}>Cancel</Button>
<Button intent="destructive" onClick={confirm}>Delete</Button>
</Dialog.Footer>
</Dialog>
// Uses native <dialog> + showModal() — gets focus trap, Esc-to-close,
// inert background, and :modal styling for free.
Sheet
Edge-anchored drawer for filter sidebars, mobile-nav drawers, quick-edit panels, notification streams. Same native foundation as Dialog (<dialog> + showModal()) — top-layer rendering, focus trap, Esc-to-close, ::backdrop overlay. The variant ladder (side + size) is the only difference at the API level.
import { Sheet, Button } from '@place/design'
import { state } from '@place/reactivity'
const open = state(false)
<Button onClick={() => open.set(true)}>Filters</Button>
<Sheet open={() => open()} onClose={() => open.set(false)}
side="right" size="md" aria-label="Filters">
<Sheet.Header>
<h3>Filters</h3>
<Button intent="ghost" size="sm" onClick={() => open.set(false)}>×</Button>
</Sheet.Header>
<Sheet.Body>
{/* ...your filter UI... */}
</Sheet.Body>
<Sheet.Footer>
<Button intent="ghost" onClick={reset}>Reset</Button>
<Button intent="primary" onClick={() => open.set(false)}>Apply</Button>
</Sheet.Footer>
</Sheet>
// Edge-anchored drawer. Same native foundation as <Dialog>
// (<dialog> + showModal) — top-layer rendering, focus trap, Esc-to-
// close, ::backdrop overlay. side: 'right' | 'left' | 'top' | 'bottom'.
// Size variants compound with side (max-w for vertical, max-h for
// horizontal). Slide-in via @starting-style. ADR 0046.
Combobox
Typeahead select with filter + WAI-ARIA Combobox v1.2 keyboard nav. Generic over the option value type — selection returns the original T, not a stringified ID. options and value are both reactive-or-static; the default case-insensitive label filter is overrideable via filter. Ships with a chevron indicator, a clear (×) button, and a checkmark on the selected row.
import { Combobox } from '@place/design'
import { state } from '@place/reactivity'
const pick = state<string | null>(null)
const OPTIONS = [
{ value: 'place', label: 'Place', hint: 'this one' },
{ value: 'next', label: 'Next.js', hint: '15+' },
{ value: 'astro', label: 'Astro', disabled: true },
// ...
]
<Combobox
options={OPTIONS}
value={() => pick()}
onChange={(v) => pick.set(v)}
placeholder="Pick one…"
aria-label="Framework"
/>
// Generic over the option value type. Default case-insensitive label
// filter (override with filter={(q, opt) => ...}). WAI-ARIA Combobox
// v1.2 keyboard nav — Arrow/Home/End/Enter/Escape/Backspace-clears.
// Reactive `options` and `value` props. Anchored popover positioning.
Customization hooks — every visual surface (input class, popover class, option class, left icon, chevron, clear button), every render slot (renderOption, renderEmpty), the filter, and the size variant are all exposed. No need to fork the source.
// Two channels: `class` (root) + `classNames` (sub-parts).
// Render slots for full structural replacement. (Tier 17-D / ADR 0050)
<Combobox
options={users}
value={() => userId()}
onChange={(id) => userId.set(id)}
// Decorations: pass any View as left icon or chevron.
leftIcon={<SearchIcon />}
chevron={<MyChevron />} // or chevron={false} to hide
// Clear button is on by default when a value is selected.
clearable={false} // pass false to hide
// Additive on the root (the flex shell).
class="border-accent"
// Typed per-subpart classNames. Each key is a known part; unknown
// keys are compile errors. No `root` key — use `class` above.
classNames={{
popover: 'shadow-2xl',
option: (st) => st.selected ? 'font-bold' : '',
clear: 'hover:text-destructive',
leftIcon: 'text-accent',
}}
// Custom row renderer — full control over the per-option JSX.
renderOption={(st) => (
<>
<Avatar src={st.option.avatarUrl} size="sm" />
<span class="flex-1">{st.option.label}</span>
<Badge>{st.option.role}</Badge>
</>
)}
// Custom empty-state node.
renderEmpty={() => <NoResults />}
// Custom filter (fuzzy, scored, multi-field, ...).
filter={(q, opt) => fuzzyScore(q, opt.label + opt.email) > 0.4}
/>
Both primitives, live. The first Combobox (inside the Sheet) uses the defaults; the second uses leftIcon + renderOption to show emoji-prefixed rows:
Toast + toast()
// Mount <Toaster /> ONCE at the app root, anywhere in the tree:
import { Toaster, toast } from '@place/design'
<Toaster anchor="bottom-right" />
// Anywhere in your app:
toast('Saved!')
toast.success('Account created')
toast.error('Network error', { duration: 0 }) // sticky
toast.warn('Heads up')
// Returns a dismiss handle:
const dismiss = toast('Working…', { duration: 0 })
// …later
dismiss()
Tooltip
import { Tooltip } from '@place/design'
<Tooltip content="Saves to draft" placement="bottom">
<Button intent="ghost" size="sm">⌄</Button>
</Tooltip>
// popover="manual" puts the bubble in the browser's top layer —
// escapes overflow:hidden / transform / z-index parents.
Menu
import { Menu, Button } from '@place/design'
const MENU_ID = 'post-actions'
<Button popovertarget={MENU_ID}>Actions</Button>
<Menu id={MENU_ID} items={[
{ kind: 'group', label: 'Edit' }, // section header
{ label: 'Open', onSelect: open, hint: '⌘O' },
{ label: 'Duplicate', onSelect: dup, hint: '⌘D' },
{ kind: 'separator' }, // horizontal divider
{ kind: 'group', label: 'Danger zone' },
{ label: 'Delete', onSelect: del, destructive: true },
]} />
// Item kinds (Tier 17-E v2):
// - 'item' (default) selectable menuitem button
// - 'separator' horizontal divider (skipped in keyboard nav)
// - 'group' non-interactive section header
//
// popover="auto" gives native light-dismiss; CSS anchor positioning
// pins the menu to the trigger button (no JS positioner).
Disclosure
Collapsible content built on native <details> + <summary>. Browser owns the open / close state and keyboard activation; exclusive accordions work via the native name attribute (no JS coordinator). Height animates to / from auto via interpolate-size + ::details-content on modern browsers; older browsers get instant open / close.
import { Disclosure } from '@place/design'
// Single section — browser owns open/close state via <details>.
<Disclosure summary="What is place-ts?">
<p>An HTML-first framework that…</p>
</Disclosure>
// Exclusive accordion — native [name] attribute (Chrome 120+ /
// Safari 17.4+ / Firefox 130+). Sibling <details name="faq">
// auto-close each other on open. Zero JS coordinator.
<Disclosure.Group>
<Disclosure name="faq" summary="Q1">A1</Disclosure>
<Disclosure name="faq" summary="Q2">A2</Disclosure>
<Disclosure name="faq" summary="Q3">A3</Disclosure>
</Disclosure.Group>
// Controlled — wire to a signal for programmatic open/close:
const open = state(false)
<Disclosure open={open} onToggle={open.set} summary="Settings">
…
</Disclosure>
// Animated height via interpolate-size + ::details-content pseudo
// (Chrome 129+, Safari 18.2+, Firefox 131+). Older browsers get
// instant open/close — graceful degradation, no polyfill.
CodeBlock
Syntax-highlighted code with a pluggable tokenizer, line numbers, line highlights, diff mode, and every visual axis controlled by typed variants. Pure SSR — no island bundle. The copy button uses a single inline runtime emitted once per page (~250 B raw, dedupes at gzip across multiple blocks).
import { CodeBlock } from '@place/design'
// Minimal — just a code string. ts is the default language.
<CodeBlock code={src} />
// Sweet spot for docs: filename + lang label + copy button.
<CodeBlock code={src} lang="tsx" filename="src/app.tsx" />
// Line numbers + line highlights — a tiny "spotlight" pattern.
<CodeBlock
code={src}
lineNumbers
highlightLines={[3, [5, 7]]}
/>
// Diff mode — first char of each line is +/-/space.
<CodeBlock code={diff} diff lang="ts" />
// Density / radius / theme variants.
<CodeBlock code={src} density="compact" radius="sm" theme="dim" />
// Wrap instead of horizontal scroll.
<CodeBlock code={src} wrap="wrap" maxHeight={400} />
Customization for the long tail: token colors via CSS variables, per-instance tokenizers, or globally registered languages.
// Override token colors per-instance via CSS variables.
<CodeBlock
code={src}
style={{
'--cb-tok-keyword': '#ff79c6',
'--cb-tok-string': '#a0e7a0',
'--cb-hl-bg': 'rgba(255, 121, 198, 0.12)',
}}
/>
// Custom tokenizer per instance (one-off languages).
import type { Tokenizer } from '@place/design'
const tokenizeJson: Tokenizer = (src) => {
// ... return Tok[] ...
}
<CodeBlock code={src} tokenize={tokenizeJson} />
// Global registration — every <CodeBlock lang="rust"> picks it up.
import { registerLanguage } from '@place/design'
registerLanguage('rust', tokenizeRust)
Slot composition for cases where the default header isn't the right shape.
// Replace the entire header with your own slot. The framework's
// default copy button disappears; consumers own the chrome.
<CodeBlock
code={src}
headerSlot={
<div class="flex w-full items-center gap-2">
<Badge intent="warning">Experimental</Badge>
<span class="ml-auto text-muted">{lineCount} lines</span>
</div>
}
showCopy={false} // explicit, since headerSlot opts out by default
/>
// Or keep the default header but append actions:
<CodeBlock
code={src}
actionsSlot={
<button type="button" onClick={openInPlayground}>
open in playground →
</button>
}
/>
Live example with line numbers + highlights + a custom slot:
function fib(n: number): number {
if (n < 2) return n
return fib(n - 1) + fib(n - 2)
}
console.log(fib(10)) // 55
Presentational: Avatar, Badge, Card
import { Avatar, Badge, Card } from '@place/design'
<Avatar name="Ada Lovelace" src={user.avatarUrl} size="md" />
<Badge intent="success">New</Badge>
<Card intent="raised" padding="md">
Card body
</Card>
Card supports named slots — same pattern as Dialog / Sheet:
Native primitives in use
- Dialog —
<dialog> + .showModal()+ the:modalpseudo-class - Toast / Tooltip / Menu —
popover="manual"/popover="auto"top-layer rendering - Field —
:user-invalid/:user-valid(validates only after interaction) - Dialog transitions —
@starting-style+transition-behavior: allow-discrete - Button spinner —
animate()from@place/reactivity/motion