
Constructive Frontend
- 6 installs
- Updated August 4, 2026
- constructive-io/constructive-skills
Build Constructive frontend UIs with the @constructive-io/ui component library, CRUD Stack cards, and dynamic _meta forms.
About
Covers building Constructive frontend UIs with the 50+ component library, CRUD Stack cards, and dynamic _meta forms that introspect any table at runtime. A developer uses it to build forms, overlays, layouts, and zero-config CRUD UIs.
- 50+ @constructive-io/ui components on Base UI and Tailwind CSS v4
- CRUD Stack cards plus dynamic _meta forms for zero-config CRUD
Constructive Frontend by the numbers
- 6 all-time installs (skills.sh)
- Ranked #1,782 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/constructive-io/constructive-skills --skill constructive-frontendAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6 |
|---|---|
| Last updated | August 4, 2026 |
| Repository | constructive-io/constructive-skills ↗ |
What it does
Build Constructive frontend UIs with the @constructive-io/ui component library, CRUD Stack cards, and dynamic _meta forms.
Files
Constructive Frontend
Build Constructive frontend UIs with the component library, CRUD Stack cards, and dynamic meta forms.
When to Apply
Use this skill when:
- Building UIs with
@constructive-io/uicomponents (forms, overlays, layout, data display, advanced inputs) - Creating CRUD actions as Stack cards (iOS-style slide-in panels)
- Building dynamic forms that introspect
_metaat runtime - Setting up theming, dark mode, OKLCH tokens
- Using the shadcn registry for Constructive components
UI Components
50+ components on Base UI + Tailwind CSS v4 with cva variants and data-slot architecture.
Install components:
npx shadcn@latest add @constructive/<component>Categories: Forms, overlays (dialogs, sheets, dropdowns), layout (sidebar, stack navigation), data display (tables, cards), advanced inputs (combobox, command palette), motion/animation.
See ui-components.md for the full component reference.
CRUD Stack Cards
Build create/edit/delete actions as slide-in Stack cards with sticky Cancel/Save/Delete footers. Cards stack naturally (e.g., confirm-delete on top of edit).
See crud-stack.md for the Stack card pattern, CardComponent structure, card API, and stacked confirm-delete.
Dynamic Meta Forms
Build fully dynamic CRUD forms for any Constructive-provisioned table — zero static field configuration. The _meta query introspects field names, types, required status, FK relationships, and mutation names at runtime.
See meta-forms.md for DynamicFormCard, locked FK pre-fill, and O2M/M2M patterns.
Reference Guide
Component Library
| Reference | Topic | Consult When |
|---|---|---|
| ui-components.md | Full component library overview | Understanding architecture, installation, component categories |
| ui-foundations.md | Theming, tokens, dark mode | Setting up OKLCH tokens, CSS variables, theme switching |
| ui-forms.md | Form components | Input, Select, Checkbox, Radio, Switch, DatePicker |
| ui-overlays.md | Overlay components | Dialog, Sheet, Popover, Tooltip |
| ui-layout.md | Layout components | Sidebar, Tabs, Accordion, Separator |
| ui-data-display.md | Data display components | Table, Card, Badge, Avatar |
| ui-advanced-inputs.md | Advanced input components | Combobox, Command palette, multi-select |
| ui-input-components.md | Input component patterns | Text, number, password, textarea variants |
| ui-card-patterns.md | Card layout patterns | Card composition, headers, footers, actions |
| ui-motion.md | Motion and animation | Transitions, enter/exit animations |
| ui-theming.md | Theme configuration | Custom themes, CSS variable overrides |
| ui-token-values.md | Design token reference | Color, spacing, typography token values |
| ui-registry.md | shadcn registry setup | Registry configuration, component installation |
| ui-combobox-api.md | Combobox API reference | Combobox props, async loading, filtering |
| ui-command-palette.md | Command palette | Keyboard shortcuts, command groups |
| ui-dropdown-menu-api.md | Dropdown menu API | Menu items, submenus, separators |
| ui-sidebar-api.md | Sidebar API reference | Collapsible sidebar, navigation items |
| ui-sheet-stacking.md | Sheet stacking patterns | Multi-level sheet navigation |
| ui-stack-navigation.md | Stack navigation | Push/pop card navigation |
CRUD & Forms
| Reference | Topic | Consult When |
|---|---|---|
| crud-stack.md | Stack card CRUD pattern | Building create/edit/delete actions as slide-in cards |
| meta-forms.md | Dynamic _meta forms | Runtime-introspected CRUD forms, FK pre-fill, related records |
Cross-References
constructive-codegen— Code generation and SDK usage (data fetching for components)pgpm— Starter kits and Next.js app boilerplate (uses these UI components) — in constructive-io/constructiveconstructive-platform— Platform core, server configuration
Constructive CRUD Stack Cards
Build any create/edit/delete action as a slide-in Stack card. Cancel/Save/Delete CTAs live in a sticky footer. Cards stack naturally — e.g., pushing a confirm-delete card on top of an edit card.
---
1. Stack Card Trigger
Every CRUD action opens a card. Push it from any button, row click, or link:
'use client';
import { useCardStack } from '@/components/ui/stack';
import { EditContactCard } from './edit-contact-card';
function EditContactButton({ contactId }: { contactId: string }) {
const stack = useCardStack();
return (
<Button
onClick={() =>
stack.push({
id: `edit-contact-${contactId}`,
title: 'Edit Contact',
description: 'Update contact details.',
Component: EditContactCard,
props: { contactId },
width: 480,
})
}
>
Edit
</Button>
);
}---
2. Card Component Structure
Every card is a CardComponent<Props> — TypeScript enforces the injected card prop:
'use client';
import type { CardComponent } from '@/components/ui/stack';
import { Button } from '@/components/ui/button';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
export type EditContactCardProps = {
contactId: string;
onSuccess?: () => void;
};
export const EditContactCard: CardComponent<EditContactCardProps> = ({
contactId,
onSuccess,
card, // ← injected: card.close(), card.push(), card.setTitle(), etc.
}) => {
const [name, setName] = useState('');
const handleSave = async () => {
await updateContact({ id: contactId, name });
showSuccessToast({ message: 'Contact updated' });
onSuccess?.();
card.close();
};
return (
<div className='flex h-full flex-col'>
{/* ── Scrollable Form Body ── */}
<div className='flex-1 space-y-4 overflow-y-auto p-4'>
<Field label='Name'>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
{/* more fields... */}
</div>
{/* ── Sticky Footer ── */}
<div className='flex items-center justify-between border-t px-4 py-3'>
<Button variant='destructive' onClick={handleDelete}>Delete</Button>
<div className='flex gap-2'>
<Button variant='outline' onClick={() => card.close()}>Cancel</Button>
<Button onClick={handleSave}>Save</Button>
</div>
</div>
</div>
);
};---
3. Card API (card prop — injected by CardStackProvider)
| Method | Description |
|---|---|
card.close() | Dismiss this card with animation |
card.push({ id, title, Component, props, width? }) | Push a new card on top of the stack |
card.setTitle(title) | Update card header title dynamically |
card.setDescription(desc) | Update subtitle |
card.updateProps(patch) | Patch card props from inside the card |
card.push behavior
By default, card.push() replaces all cards above the current card, then pushes the new one. Use { append: true } to push purely on top without replacing:
card.push({ id: '...', Component: MyCard, props: {...} }); // default: replaces above
card.push({ id: '...', Component: MyCard, props: {...} }, { append: true }); // pure append---
4. Deferred Data Loading (useCardReady)
Use useCardReady() to delay data fetching until the card's enter animation completes. This prevents janky mid-animation fetches and dropped frames:
import { useCardReady } from '@/components/ui/stack';
export const EditContactCard: CardComponent<Props> = ({ contactId }) => {
const { isReady } = useCardReady(); // true after ~220ms (animation completes)
const { data } = useContactQuery({
variables: { id: contactId },
enabled: isReady, // ← only fetches after animation
});
if (!isReady || !data) {
return <ContactFormSkeleton />;
}
// ... render form
};---
5. Stacked Confirm Delete
Push a confirm card instead of an alert dialog. Stacks visually over the edit card:
const handleDeleteClick = () => {
card.push({
id: `confirm-delete-${contactId}`,
title: 'Delete Contact?',
description: 'This cannot be undone.',
Component: ConfirmDeleteCard,
props: {
message: 'Are you sure you want to delete this contact?',
onConfirm: async () => {
await deleteContact({ id: contactId });
showSuccessToast({ message: 'Contact deleted' });
card.close(); // closes confirm card (top of stack)
card.close(); // closes edit card
},
},
width: 400,
});
};
// ── ConfirmDeleteCard ──
type ConfirmDeleteCardProps = {
message: string;
onConfirm: () => Promise<void>;
};
const ConfirmDeleteCard: CardComponent<ConfirmDeleteCardProps> = ({ message, onConfirm, card }) => {
const [isDeleting, setIsDeleting] = useState(false);
const handleConfirm = async () => {
setIsDeleting(true);
try { await onConfirm(); }
finally { setIsDeleting(false); }
};
return (
<div className='flex h-full flex-col'>
<div className='flex-1 p-4'>
<p className='text-muted-foreground text-sm'>{message}</p>
</div>
<div className='flex justify-end gap-2 border-t px-4 py-3'>
<Button variant='outline' onClick={() => card.close()} disabled={isDeleting}>Cancel</Button>
<Button variant='destructive' onClick={handleConfirm} disabled={isDeleting}>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
</div>
</div>
);
};---
6. CardStackProvider Setup (Root Layout)
The provider must be high in the tree so all pages can push cards. Include ClientOnlyStackViewport to avoid hydration mismatches:
// app/layout.tsx
import { CardStackProvider } from '@/components/ui/stack';
import { ClientOnlyStackViewport } from '@/components/client-only-stack-viewport';
export default function RootLayout({ children }) {
return (
<html>
<body>
<CardStackProvider layoutMode='side-by-side' defaultPeekOffset={48}>
{children}
<ClientOnlyStackViewport />
</CardStackProvider>
</body>
</html>
);
}---
7. CardSpec Options (Full Reference)
stack.push({
id: 'unique-card-id', // required — prevents duplicate cards
title: 'Edit Contact', // shown in card header
description: 'Update details', // subtitle in header
headerSize: 'md', // 'sm' | 'md' | 'lg'
Component: EditContactCard, // CardComponent<Props>
props: { contactId }, // typed props (excluding injected card prop)
width: 480, // default: 480px
peekOffset: 24, // px peeking behind cards above (default: 48)
allowCover: false, // allow being fully covered (default: false)
backdrop: true, // show backdrop behind stack (default: true)
onClose: () => console.log('closed'), // callback on any close method
});---
8. Using DynamicFormCard (from constructive-meta-forms)
Combine both skills: the Stack card trigger pattern (this skill) with schema-driven forms (constructive-meta-forms). DynamicFormCard introspects _meta at runtime and renders the correct inputs for any table — no static field config needed:
import { DynamicFormCard } from '@/components/crm/dynamic-form-card';
import { useCardStack } from '@/components/ui/stack';
function ContactDetailPage({ contactId }) {
const stack = useCardStack();
const handleEdit = () => {
stack.push({
id: `edit-contact-${contactId}`,
title: 'Edit Contact',
description: 'Update contact fields.',
Component: DynamicFormCard, // from constructive-meta-forms
props: {
tableName: 'Contact',
recordId: contactId,
},
width: 480,
});
};
return <Button onClick={handleEdit}>Edit</Button>;
}For static forms with handcrafted fields (more control over layout/validation), use the CardComponent pattern from Section 2 above.
---
Troubleshooting
| Issue | Solution |
|---|---|
useCardStack must be used within a CardStackProvider | Ensure CardStackProvider is in root layout.tsx |
| Card doesn't slide in | Check ClientOnlyStackViewport is mounted (prevents hydration mismatch) |
| Card pushes but nothing appears | Verify CardStackViewport (or ClientOnlyStackViewport) is rendered in tree |
| Stale card props after update | Use card.updateProps(patch) or re-push with new props |
Constructive _meta Dynamic Forms
Build fully dynamic CRUD forms for any Constructive-provisioned table — zero static field configuration required. The _meta query built into every Constructive app-public GraphQL endpoint tells you field names, types, required status, FK relationships, and mutation names — all at runtime.
One component. Any table. No codegen needed for forms.
---
1. What _meta gives you
query GetMeta {
_meta {
tables {
name
fields {
name isNotNull hasDefault isPrimaryKey isForeignKey description
type { pgType gqlType isArray subtype }
enumValues { name values }
}
inflection { tableType createInputType patchType filterType orderByType }
query { all one create update delete }
primaryKeyConstraints { name fields { name } }
foreignKeyConstraints { name fields { name } referencedTable referencedFields }
uniqueConstraints { name fields { name } }
storage { isFilesTable isBucketsTable }
search { algorithms columns { name algorithm } hasUnifiedSearch }
i18n { translationTable translatableFields { name type } }
realtime { subscriptionFieldName }
}
}
}fields→ names, types, nullability, defaults — enough to render any inputfields.enumValues→ allowed values for enum fields — auto-render<select>dropdownsinflection→ exact GraphQL type names for mutations (CreateContactInput,ContactPatch)query→ exact mutation/query resolver names (createContact,updateContact,deleteContact)foreignKeyConstraints→ which fields are FKs and what table they referencestorage→ detect file/bucket tables — render file upload UIssearch→ which algorithms are active — render appropriate search UXi18n→ which fields are translatable — render language switchersrealtime→ subscription field name — auto-subscribe to changes- Fetch once with `staleTime: Infinity` — schema never changes at runtime
Full `_meta` reference: See `constructive-orm/references/query-meta-introspection.md` for complete TypeScript types, all fields, and smart tag detection details.
---
2. TypeScript types
// src/types/meta.ts
export type MetaField = {
name: string;
isNotNull: boolean;
hasDefault: boolean;
type: { pgType: string; gqlType: string; isArray: boolean };
};
export type MetaTable = {
name: string;
fields: MetaField[];
inflection: {
tableType: string;
createInputType: string;
patchType: string | null;
filterType: string | null;
orderByType: string;
};
query: {
all: string; // e.g. "contacts"
one: string | null; // ⚠️ may be a non-existent root field — see §3 bug note
create: string | null;
update: string | null;
delete: string | null;
};
primaryKeyConstraints: Array<{ name: string; fields: { name: string }[] }>;
foreignKeyConstraints: Array<{
name: string;
fields: { name: string }[];
referencedTable: string;
referencedFields: string[];
}>;
uniqueConstraints: Array<{ name: string; fields: { name: string }[] }>;
};---
3. ⚠️ Platform bug: query.one returns a non-existent root field
_meta.query.one returns the singular name (e.g. "contact") but the Constructive GraphQL root only exposes plural queries (e.g. contacts). Using query.one as the root field will fail.
Fix — always use `query.all` + `condition: { id: $id }`:
function buildFetchQuery(table: MetaTable): string {
const fieldNames = table.fields.map((f) => f.name).join('\n ');
// Use query.all with a condition filter + read nodes[0]
// DO NOT use query.one — it returns a non-existent root field name
return `
query DynamicFetch($id: UUID!) {
${table.query.all}(condition: { id: $id }) {
nodes { ${fieldNames} }
}
}
`;
}
// Read the result:
const result = data[table.query.all].nodes[0] as Record<string, unknown> | undefined;---
4. useMeta / useTableMeta hooks
// src/lib/meta/use-meta.ts
'use client';
import { useQuery } from '@tanstack/react-query';
import { CRM_ENDPOINT } from '@/components/crm/crm-provider';
import { TokenManager } from '@/lib/auth/token-manager';
import type { MetaTable } from '@/types/meta';
const META_QUERY = `query GetMeta {
_meta {
tables {
name
fields { name isNotNull hasDefault type { pgType gqlType isArray } }
inflection { tableType createInputType patchType filterType orderByType }
query { all one create update delete }
primaryKeyConstraints { name fields { name } }
foreignKeyConstraints { name fields { name } referencedTable referencedFields }
uniqueConstraints { name fields { name } }
}
}
}`;
async function fetchMeta(): Promise<{ _meta: { tables: MetaTable[] } }> {
const { token } = TokenManager.getToken('schema-builder');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (token) headers['Authorization'] = `Bearer ${token.accessToken}`;
const res = await fetch(CRM_ENDPOINT, {
method: 'POST', headers,
body: JSON.stringify({ query: META_QUERY }),
});
if (!res.ok) throw new Error(`_meta fetch failed: ${res.status}`);
const json = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message ?? '_meta error');
return json.data;
}
export function useMeta() {
return useQuery({ queryKey: ['_meta'], queryFn: fetchMeta, staleTime: Infinity });
}
export function useTableMeta(tableName: string): MetaTable | null {
const { data } = useMeta();
return data?._meta.tables.find((t) => t.name === tableName) ?? null;
}---
5. Field renderer utilities
// src/lib/meta/field-renderer.ts
import type { MetaField } from '@/types/meta';
/** System fields — always skip in forms (auto-managed by Constructive) */
export const SYSTEM_FIELDS = new Set([
'id', 'entityId', 'createdAt', 'updatedAt',
'created_at', 'updated_at', 'entity_id',
]);
export type FieldInputType =
| 'text' | 'textarea' | 'number' | 'boolean'
| 'date' | 'datetime' | 'uuid' | 'json' | 'select' | 'hidden';
const TEXTAREA_HINTS = ['bio', 'description', 'notes', 'body', 'content', 'summary', 'details'];
export function getInputType(field: MetaField, isForeignKey: boolean): FieldInputType {
if (SYSTEM_FIELDS.has(field.name)) return 'hidden';
if (isForeignKey) return 'select';
const pg = field.type.pgType.toLowerCase();
switch (pg) {
case 'text': case 'varchar': case 'citext':
return TEXTAREA_HINTS.some((h) => field.name.toLowerCase().includes(h)) ? 'textarea' : 'text';
case 'int2': case 'int4': case 'int8':
case 'float4': case 'float8': case 'numeric': return 'number';
case 'bool': case 'boolean': return 'boolean';
case 'date': return 'date';
case 'timestamp': case 'timestamptz': return 'datetime';
case 'uuid': return 'uuid';
case 'json': case 'jsonb': return 'json';
default: return 'text';
}
}
/**
* A field is required if it's NOT NULL AND has no server-side default.
* hasDefault=true = Constructive auto-generates the value (ids, timestamps, etc.) — never require in forms.
*/
export function isRequiredField(field: MetaField): boolean {
return field.isNotNull && !field.hasDefault;
}
/** camelCase → "Title Case" label */
export function toLabel(fieldName: string): string {
return fieldName.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase()).trim();
}Required field rule
isNotNull | hasDefault | In form |
|---|---|---|
true | false | Required input |
true | true | Skip in create (id, timestamps), optional in edit |
false | anything | Optional input |
---
6. DynamicField component
Handles all pgTypes automatically. Add locked + lockedLabel for pre-filled FK context (see §8).
// src/components/crm/dynamic-field.tsx
'use client';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { getInputType, SYSTEM_FIELDS, toLabel } from '@/lib/meta/field-renderer';
import type { MetaField } from '@/types/meta';
import { Lock } from 'lucide-react';
type DynamicFieldProps = {
field: MetaField;
value: unknown;
onChange: (value: unknown) => void;
isForeignKey?: boolean;
/** Pre-set from context — visible but not editable */
locked?: boolean;
/** Human-readable label for locked field (e.g. "Kristopher Floyd" instead of a UUID) */
lockedLabel?: string;
error?: string;
};
export function DynamicField({
field, value, onChange,
isForeignKey = false, locked = false, lockedLabel, error,
}: DynamicFieldProps) {
if (SYSTEM_FIELDS.has(field.name)) return null;
const inputType = getInputType(field, isForeignKey);
const label = toLabel(field.name);
const required = field.isNotNull && !field.hasDefault;
// ── Locked: visible, disabled, not editable ──
if (locked) {
const displayValue = lockedLabel ?? (typeof value === 'string' ? value : String(value ?? ''));
return (
<Field label={label} required={false}>
<div className="relative">
<Input
value={displayValue}
readOnly disabled
className="bg-muted/40 pr-8 text-muted-foreground cursor-default"
/>
<Lock className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/60" />
</div>
{lockedLabel && (
<p className="mt-1 text-xs text-muted-foreground font-mono">{String(value)}</p>
)}
</Field>
);
}
if (inputType === 'hidden') return null;
if (inputType === 'boolean') {
return (
<div className="flex items-center gap-3 py-1">
<Switch id={field.name} checked={(value as boolean) ?? false} onCheckedChange={onChange} />
<Label htmlFor={field.name} className="cursor-pointer">{label}</Label>
{error && <p className="text-destructive text-sm">{error}</p>}
</div>
);
}
if (inputType === 'textarea') {
return (
<Field label={label} required={required} error={error}>
<Textarea value={(value as string) ?? ''} onChange={(e) => onChange(e.target.value)} rows={4} />
</Field>
);
}
if (inputType === 'json') {
return (
<Field label={label} required={required} error={error} description="JSON value">
<Textarea
value={typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2)}
onChange={(e) => { try { onChange(JSON.parse(e.target.value)); } catch { onChange(e.target.value); } }}
rows={6} className="font-mono text-xs"
/>
</Field>
);
}
if (inputType === 'number') {
return (
<Field label={label} required={required} error={error}>
<Input type="number" value={(value as number) ?? ''}
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
</Field>
);
}
if (inputType === 'date') {
return (
<Field label={label} required={required} error={error}>
<Input type="date" value={(value as string) ?? ''} onChange={(e) => onChange(e.target.value)} />
</Field>
);
}
if (inputType === 'datetime') {
return (
<Field label={label} required={required} error={error}>
<Input type="datetime-local" value={(value as string) ?? ''} onChange={(e) => onChange(e.target.value)} />
</Field>
);
}
if (inputType === 'uuid') {
return (
<Field label={label} required={required} error={error}>
<Input value={(value as string) ?? ''} onChange={(e) => onChange(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" className="font-mono" />
</Field>
);
}
if (inputType === 'select') {
// FK field — raw UUID input until EntitySearch is built
return (
<Field label={label} required={required} error={error} description="Foreign key — paste UUID">
<Input value={(value as string) ?? ''} onChange={(e) => onChange(e.target.value)}
placeholder={`${label} ID…`} className="font-mono text-sm" />
</Field>
);
}
return (
<Field label={label} required={required} error={error}>
<Input value={(value as string) ?? ''} onChange={(e) => onChange(e.target.value)} />
</Field>
);
}---
7. DynamicFormCard — full implementation
// src/components/crm/dynamic-form-card.tsx
'use client';
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { CardComponent } from '@/components/ui/stack';
import { useCardReady } from '@/components/ui/stack';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { showSuccessToast, showErrorToast } from '@/components/ui/toast';
import { SYSTEM_FIELDS, isRequiredField } from '@/lib/meta/field-renderer';
import { useTableMeta } from '@/lib/meta/use-meta';
import { DynamicField } from './dynamic-field';
import { Loader2 } from 'lucide-react';
import { CRM_ENDPOINT } from '@/components/crm/crm-provider';
import { TokenManager } from '@/lib/auth/token-manager';
import type { MetaTable } from '@/types/meta';
export type DynamicFormCardProps = {
/** Constructive table type name, e.g. 'Contact', 'Note', 'Deal' */
tableName: string;
/** Existing record ID — omit for create mode */
recordId?: string;
/**
* Pre-set field values from context (typically FK fields).
* e.g. { contactId: "uuid" } when adding a Note from a Contact page.
* These fields are rendered as visible-but-locked (disabled, 🔒 icon).
*/
defaultValues?: Record<string, unknown>;
/**
* Human-readable display labels for locked fields.
* e.g. { contactId: "Kristopher Floyd" } → shows name, UUID as helper text.
*/
defaultValueLabels?: Record<string, string>;
/** Called after successful save or delete */
onSuccess?: () => void;
};
async function crmRequest(query: string, variables?: Record<string, unknown>) {
const { token } = TokenManager.getToken('schema-builder');
const headers: Record<string, string> = {
'Content-Type': 'application/json', Accept: 'application/json',
};
if (token) headers['Authorization'] = `Bearer ${token.accessToken}`;
const res = await fetch(CRM_ENDPOINT, {
method: 'POST', headers, body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`GraphQL error: ${res.status}`);
const json = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data;
}
function buildFetchQuery(table: MetaTable): string {
const fields = table.fields.map((f) => f.name).join('\n ');
// Use query.all + condition — NOT query.one (platform bug: query.one is non-existent root field)
return `
query DynamicFetch($id: UUID!) {
${table.query.all}(condition: { id: $id }) {
nodes { ${fields} }
}
}
`;
}
export const DynamicFormCard: CardComponent<DynamicFormCardProps> = ({
tableName, recordId, defaultValues, defaultValueLabels, onSuccess, card,
}) => {
const isEditMode = !!recordId;
const { isReady } = useCardReady();
const tableMeta = useTableMeta(tableName);
const queryClient = useQueryClient();
// Seed formValues with defaultValues so locked fields are in place immediately
const [formValues, setFormValues] = useState<Record<string, unknown>>(defaultValues ?? {});
const [initialized, setInitialized] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
const fkFields = useMemo(
() => new Set(tableMeta?.foreignKeyConstraints.flatMap((fk) => fk.fields.map((f) => f.name)) ?? []),
[tableMeta],
);
const editableFields = useMemo(
() => tableMeta?.fields.filter((f) => !SYSTEM_FIELDS.has(f.name)) ?? [],
[tableMeta],
);
// Locked = pre-set from defaultValues, cannot be changed by user
const lockedFields = useMemo(
() => new Set(Object.keys(defaultValues ?? {})),
[defaultValues],
);
const { data: existingData, isLoading: isLoadingRecord } = useQuery({
queryKey: ['dynamic-record', tableName, recordId],
queryFn: async () => {
const query = buildFetchQuery(tableMeta!);
const data = await crmRequest(query, { id: recordId });
return (data[tableMeta!.query.all]?.nodes?.[0] ?? null) as Record<string, unknown> | null;
},
enabled: isReady && isEditMode && !!tableMeta,
staleTime: 0,
});
// Initialize form from existing record — locked fields take precedence
if (existingData && !initialized) {
const initial: Record<string, unknown> = { ...(defaultValues ?? {}) };
for (const field of editableFields) {
if (!lockedFields.has(field.name) && existingData[field.name] !== undefined) {
initial[field.name] = existingData[field.name];
}
}
setFormValues(initial);
setInitialized(true);
}
const setFieldValue = (name: string, value: unknown) => {
setFormValues((prev) => ({ ...prev, [name]: value }));
setFieldErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
};
// Validate — skip locked fields (always satisfied by caller)
const validate = (): boolean => {
const errors: Record<string, string> = {};
for (const field of editableFields) {
if (lockedFields.has(field.name)) continue;
if (isRequiredField(field)) {
const val = formValues[field.name];
if (val === undefined || val === null || val === '') {
errors[field.name] = `${field.name} is required`;
}
}
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = async () => {
if (!tableMeta || !validate()) return;
setIsSaving(true);
try {
const input: Record<string, unknown> = {};
for (const field of editableFields) {
const val = formValues[field.name];
if (val !== undefined && val !== '') input[field.name] = val;
}
if (isEditMode) {
const mutation = `
mutation DynamicUpdate($id: UUID!, $patch: ${tableMeta.inflection.patchType}!) {
${tableMeta.query.update}(input: { id: $id, patch: $patch }) { clientMutationId }
}`;
await crmRequest(mutation, { id: recordId, patch: input });
} else {
const mutation = `
mutation DynamicCreate($input: ${tableMeta.inflection.createInputType}!) {
${tableMeta.query.create}(input: { input: $input }) { clientMutationId }
}`;
await crmRequest(mutation, { input });
}
await queryClient.invalidateQueries({ queryKey: [tableMeta.query.all] });
if (isEditMode) await queryClient.invalidateQueries({ queryKey: ['dynamic-record', tableName, recordId] });
showSuccessToast({ message: isEditMode ? `${tableName} updated` : `${tableName} created` });
onSuccess?.();
card.close();
} catch (err) {
showErrorToast({
message: `Failed to ${isEditMode ? 'update' : 'create'} ${tableName}`,
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
if (!tableMeta || !recordId) return;
card.push({
id: `confirm-delete-${recordId}`,
title: `Delete ${tableName}?`,
description: 'This cannot be undone.',
Component: ConfirmDeleteCard,
props: {
tableName, recordId,
deleteMutation: tableMeta.query.delete!,
tableType: tableMeta.inflection.tableType,
listQueryKey: tableMeta.query.all,
onSuccess: () => { onSuccess?.(); card.close(); },
},
width: 400,
});
};
if (!tableMeta || (isEditMode && isLoadingRecord && !initialized)) {
return (
<div className="flex h-full flex-col p-4 space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-9 w-full" />
</div>
))}
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{editableFields.map((field) => (
<DynamicField
key={field.name}
field={field}
value={formValues[field.name]}
onChange={(val) => setFieldValue(field.name, val)}
isForeignKey={fkFields.has(field.name)}
locked={lockedFields.has(field.name)}
lockedLabel={defaultValueLabels?.[field.name]}
error={fieldErrors[field.name]}
/>
))}
{editableFields.length === 0 && (
<p className="text-muted-foreground py-8 text-center text-sm">No editable fields.</p>
)}
</div>
<div className="flex items-center justify-between border-t px-4 py-3">
{isEditMode && tableMeta.query.delete ? (
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={isSaving}>Delete</Button>
) : <div />}
<div className="flex gap-2">
<Button variant="outline" onClick={() => card.close()} disabled={isSaving}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Saving…</>
: isEditMode ? 'Save Changes' : `Create ${tableName}`}
</Button>
</div>
</div>
</div>
);
};ConfirmDeleteCard (add in same file)
type ConfirmDeleteCardProps = {
tableName: string; recordId: string; deleteMutation: string;
tableType: string; listQueryKey: string; onSuccess?: () => void;
};
const ConfirmDeleteCard: CardComponent<ConfirmDeleteCardProps> = ({
tableName, recordId, deleteMutation, tableType, listQueryKey, onSuccess, card,
}) => {
const queryClient = useQueryClient();
const [isDeleting, setIsDeleting] = useState(false);
const handleConfirm = async () => {
setIsDeleting(true);
try {
const mutation = `mutation DynamicDelete($id: UUID!) {
${deleteMutation}(input: { id: $id }) { deleted${tableType}Id }
}`;
await crmRequest(mutation, { id: recordId });
await queryClient.invalidateQueries({ queryKey: [listQueryKey] });
showSuccessToast({ message: `${tableName} deleted` });
onSuccess?.(); card.close();
} catch (err) {
showErrorToast({
message: `Failed to delete ${tableName}`,
description: err instanceof Error ? err.message : 'Unknown error',
});
setIsDeleting(false);
}
};
return (
<div className="flex h-full flex-col">
<div className="flex-1 p-4">
<p className="text-muted-foreground text-sm">
Are you sure you want to delete this {tableName.toLowerCase()}? This cannot be undone.
</p>
</div>
<div className="flex justify-end gap-2 border-t px-4 py-3">
<Button variant="outline" onClick={() => card.close()} disabled={isDeleting}>Cancel</Button>
<Button variant="destructive" onClick={handleConfirm} disabled={isDeleting}>
{isDeleting ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Deleting…</> : `Delete ${tableName}`}
</Button>
</div>
</div>
);
};---
8. Locked FK pre-fill — related records from context
When opening a form from a parent record page (e.g. adding a Note from a Contact detail page), pass defaultValues to pre-set and lock the FK field. The user sees it but cannot change it.
// On Kristopher Floyd's contact page:
const contactFullName = `${contact.firstName} ${contact.lastName}`;
// ── Create a new note (+ Add Note button) ──
stack.push({
id: `add-note-${contactId}`,
title: 'Add Note',
description: `New note for ${contactFullName}`,
Component: DynamicFormCard,
props: {
tableName: 'Note',
defaultValues: { contactId }, // pre-set FK, locked
defaultValueLabels: { contactId: contactFullName }, // show name, not UUID
onSuccess: () => queryClient.invalidateQueries({ queryKey: noteKeys.lists() }),
},
width: 480,
});
// ── Edit an existing note (click note row) ──
stack.push({
id: `edit-note-${noteId}`,
title: 'Edit Note',
Component: DynamicFormCard,
props: {
tableName: 'Note',
recordId: noteId,
defaultValues: { contactId }, // locked even in edit — can't reassign owner
defaultValueLabels: { contactId: contactFullName },
onSuccess: () => queryClient.invalidateQueries({ queryKey: noteKeys.lists() }),
},
width: 480,
});How it renders:
Contact Idfield → disabled input showing "Kristopher Floyd" + 🔒 icon- UUID shown as small helper text below
- Field cannot be changed by user
- Value is included in the save mutation automatically
- Validation skips locked fields (they're always satisfied)
Generic rule: defaultValues works for any FK on any table. The _meta FK constraint map tells you which fields are FKs — you don't need to hardcode anything.
---
9. Usage patterns
import { DynamicFormCard } from '@/components/crm/dynamic-form-card';
// ── Create any record ──
stack.push({ id: 'new-contact', title: 'New Contact',
Component: DynamicFormCard, props: { tableName: 'Contact' }, width: 480 });
// ── Edit any record ──
stack.push({ id: `edit-${id}`, title: 'Edit Contact',
Component: DynamicFormCard, props: { tableName: 'Contact', recordId: id }, width: 480 });
// ── Related record (O2M) from parent page ──
stack.push({ id: `add-note-${contactId}`, title: 'Add Note',
Component: DynamicFormCard,
props: { tableName: 'Note', defaultValues: { contactId }, defaultValueLabels: { contactId: name } },
width: 480 });
// ── Any table, same API ──
stack.push({ id: 'new-deal', title: 'New Deal',
Component: DynamicFormCard, props: { tableName: 'Deal' }, width: 480 });---
10. pgType → input type reference
| pgType | Input | Notes |
|---|---|---|
text, varchar, citext | <Input> | <Textarea> if name contains bio/description/notes/body |
int2/4/8, float4/8, numeric | <Input type="number"> | |
bool, boolean | <Switch> | |
date | <Input type="date"> | |
timestamp, timestamptz | <Input type="datetime-local"> | |
uuid (FK) | Locked or UUID input | Use defaultValues to lock from context; future: <EntitySearch> |
uuid (non-FK) | <Input> mono | Rare — raw UUID |
json, jsonb | <Textarea> mono | JSON.parse / stringify |
---
11. Future extensions
| Feature | How |
|---|---|
| EntitySearch for FK fields | Replace select case in DynamicField with an <EntitySearch tableName={fk.referencedTable}> component that fetches + autocompletes |
| Array fields | Handle isArray: true in MetaField — render <TagInput> for text[] |
| Enum fields | Query __schema for enum values — render <Select> |
| Package | Extract DynamicFormCard, DynamicField, useMeta, field-renderer into @constructive/meta-forms npm package so any Constructive-backed app gets this for free |
---
12. Troubleshooting
| Issue | Fix |
|---|---|
| Single-record fetch fails / field empty | Use `query.all + condition: { id: $id }` and read `nodes[0]` — query.one returns a non-existent root field (platform bug) |
_meta returns empty tables | Check auth headers — _meta requires an authenticated request |
| Mutation fails with GraphQL type error | Verify inflection.patchType / createInputType match your schema version |
| Form shows no editable fields | All fields in SYSTEM_FIELDS — check provisioned columns |
| Required validation on system fields | Bug — verify SYSTEM_FIELDS set covers all auto-managed field names |
| Edit form is empty on open | Check useCardReady() gate — data fetches only after card animation completes |
| FK shows UUID instead of name | Use defaultValueLabels prop, or build EntitySearch (future work) |
hasDefault=true field marked required | Bug in isRequiredField — must check !hasDefault |
constructive-ui-advanced-inputs
Advanced input components from @constructive-io/ui for building rich selection, search, and editing interfaces beyond basic text inputs.
Autocomplete
'use client';
import {
Autocomplete, AutocompleteInput, AutocompletePopup,
AutocompleteItem, AutocompleteList, AutocompleteEmpty,
AutocompleteGroup, AutocompleteGroupLabel,
} from '@constructive-io/ui/autocomplete';
const fruits = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'];
function FruitPicker() {
return (
<Autocomplete>
<AutocompleteInput placeholder="Search fruits..." showClear />
<AutocompletePopup>
<AutocompleteList>
<AutocompleteEmpty>No results found</AutocompleteEmpty>
{fruits.map((fruit) => (
<AutocompleteItem key={fruit} value={fruit}>
{fruit}
</AutocompleteItem>
))}
</AutocompleteList>
</AutocompletePopup>
</Autocomplete>
);
}Built on @base-ui/react/autocomplete. Single-value only. Props on AutocompleteInput: startAddon, showTrigger, showClear.
Additional exports: AutocompleteTrigger, AutocompleteSeparator, AutocompleteValue, AutocompleteClear, AutocompleteStatus, AutocompleteRow, AutocompleteCollection.
Combobox
'use client';
import {
Combobox, ComboboxInput, ComboboxTrigger, ComboboxContent,
ComboboxItem, ComboboxList, ComboboxEmpty, ComboboxGroup, ComboboxGroupLabel,
} from '@constructive-io/ui/combobox';
const frameworks = [
{ value: 'next', label: 'Next.js' },
{ value: 'remix', label: 'Remix' },
{ value: 'astro', label: 'Astro' },
];
function FrameworkSelect() {
const [value, setValue] = useState('');
return (
<Combobox value={value} onValueChange={setValue}>
<ComboboxTrigger>
<ComboboxInput placeholder="Select framework..." />
</ComboboxTrigger>
<ComboboxContent>
<ComboboxList>
<ComboboxEmpty>No frameworks found</ComboboxEmpty>
{frameworks.map((fw) => (
<ComboboxItem key={fw.value} value={fw.value}>
{fw.label}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}Multiple mode (chips):
import { ComboboxChips, ComboboxChip, ComboboxChipRemove } from '@constructive-io/ui/combobox';
<Combobox multiple value={selected} onValueChange={setSelected}>
<ComboboxTrigger>
<ComboboxChips>
{selected.map((val) => (
<ComboboxChip key={val} value={val}>
{val}
<ComboboxChipRemove />
</ComboboxChip>
))}
</ComboboxChips>
<ComboboxInput placeholder="Add tags..." />
</ComboboxTrigger>
<ComboboxContent>
<ComboboxList>
{options.map((opt) => (
<ComboboxItem key={opt} value={opt}>{opt}</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>Also exports: useComboboxFilter hook for client-side filtering, ComboboxSeparator, ComboboxValue, ComboboxClear, ComboboxStatus, ComboboxRow, ComboboxCollection, ComboboxPopup.
See references/combobox-api.md for full type definitions.
MultiSelect
'use client';
import { MultiSelect, type MultiSelectOption } from '@constructive-io/ui/multi-select';
const options: MultiSelectOption[] = [
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'angular', label: 'Angular' },
{ value: 'svelte', label: 'Svelte' },
];
function SkillsPicker() {
const [selected, setSelected] = useState<string[]>([]);
return (
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
placeholder="Select skills..."
maxCount={3}
/>
);
}Props: options, value, onValueChange, placeholder, maxCount (max badges before "+N"), singleLine (badges in one line), variant (badge variant), animation (badge entry animation).
Supports grouped options via MultiSelectGroup. Custom badge colors/gradients. Responsive maxCount ({ mobile: 1, tablet: 2, desktop: 3 }).
Imperative ref: reset(), getSelectedValues(), setSelectedValues(), clear(), focus().
Tags
'use client';
import {
Tags, TagsTrigger, TagsValue, TagsContent,
TagsInput, TagsList, TagsItem, TagsEmpty,
} from '@constructive-io/ui/tags';
const availableTags = ['TypeScript', 'JavaScript', 'Python', 'Rust', 'Go'];
function TagPicker() {
const [selected, setSelected] = useState<string[]>([]);
return (
<Tags value={selected} onValueChange={setSelected}>
<TagsTrigger>
<TagsValue />
</TagsTrigger>
<TagsContent>
<TagsInput placeholder="Search tags..." />
<TagsList>
<TagsEmpty>No tags found</TagsEmpty>
{availableTags.map((tag) => (
<TagsItem key={tag} value={tag}>{tag}</TagsItem>
))}
</TagsList>
</TagsContent>
</Tags>
);
}Wraps Command + Popover. Create-on-enter supported. Tracks width via ResizeObserver for popup sizing.
Also supports TagsGroup for grouping.
RecordPicker
'use client';
import { RecordPicker } from '@constructive-io/ui/record-picker';
type User = { id: string; name: string; email: string };
function UserPicker({ users, linkedIds, onLink, onUnlink }: {
users: User[];
linkedIds: string[];
onLink: (ids: string[]) => void;
onUnlink: (ids: string[]) => void;
}) {
return (
<RecordPicker
records={users}
linkedRecordIds={linkedIds}
getRecordId={(u) => u.id}
getRecordLabel={(u) => u.name}
onLink={onLink}
onUnlink={onUnlink}
/>
);
}Uses matchSorter for fuzzy search (debounced 300ms). Separates linked vs available records. Uses Checkbox for selection. Generic via prop callbacks.
Calendar
'use client';
import { Calendar, RangeCalendar } from '@constructive-io/ui/calendar-rac';
import { today, getLocalTimeZone } from '@internationalized/date';
// Single date
<Calendar
value={date}
onChange={setDate}
minValue={today(getLocalTimeZone())}
/>
// Date range
<RangeCalendar
value={range}
onChange={setRange}
/>Peer dependencies: react-aria-components ^1, @internationalized/date ^3. Built on React Aria Components for full a11y, i18n, and keyboard navigation.
JsonInput
'use client';
import { JsonInput, JsonEditor, validateJson } from '@constructive-io/ui/json-input';
// Full input with validation UI
<JsonInput
value={jsonString}
onChange={setJsonString}
height="200px"
/>
// Raw editor only (ace editor)
<JsonEditor
value={jsonString}
onChange={setJsonString}
/>
// Validation utility
const { valid, error } = validateJson(jsonString);JsonInput wraps react-ace (lazy-loaded) with JSON mode, validation status indicator (loading -> success/error with 400ms debounce), and "Format JSON" button. Peer deps: react-ace ^14, ace-builds ^1.
Decision Guide
| Need | Component |
|---|---|
| Search + select one value | Autocomplete (simple) or Combobox (richer) |
| Search + select multiple | Combobox (multiple mode with chips) or MultiSelect (badge display) |
| Tag-style multi-picker | Tags (create-on-enter, command palette style) |
| Link/unlink records | RecordPicker (fuzzy search, checkbox selection) |
| Date selection | Calendar / RangeCalendar |
| JSON editing | JsonInput (with validation) or JsonEditor (raw) |
Autocomplete vs Combobox: Autocomplete is simpler (single value, Base UI autocomplete). Combobox has richer features (multiple mode, chips, groups, custom rendering).
MultiSelect vs Combobox (multiple): MultiSelect is a single component with badge display, maxCount, and imperative ref. Combobox multiple mode gives you full control over chip rendering and layout via composable sub-components.
Tags vs MultiSelect: Tags supports create-on-enter for free-form values. MultiSelect is constrained to predefined options only.
Best Practices
- Use Autocomplete for simple search-and-select; Combobox for complex scenarios
- MultiSelect is best when options are known and finite (like categories)
- Tags is best for free-form tagging with optional suggestions
- RecordPicker is designed for relational data -- link/unlink pattern
- Calendar requires
react-aria-componentsand@internationalized/datepeer deps - JsonInput lazy-loads the ace editor -- use Suspense boundaries if needed
- All advanced inputs require
'use client' - Use deep imports:
@constructive-io/ui/comboboxnot@constructive-io/ui - Tailwind v4 syntax: use
bg-black/50notbg-opacity-*,shadow-xsnotshadow-sm(v3) - For Combobox in modals/dialogs, the popup uses
useFloatingOverlayPortalProps()for correct z-index stacking - Debounce async searches in Autocomplete/Combobox -- the components only filter client-side by default
useComboboxFilteris client-side only; for server-side filtering, manage the items list yourself
Card Patterns Reference
Usage patterns for the Card component from @constructive-io/ui/card — variants, layout compositions, and common dashboard patterns.
Card Variants
The Card component uses cva with 5 variants:
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter } from '@constructive-io/ui/card';
// Default — standard bordered card with subtle shadow
<Card>...</Card>
// Elevated — more prominent shadow for featured content
<Card variant="elevated">...</Card>
// Flat — no shadow, stronger border for dense layouts
<Card variant="flat">...</Card>
// Ghost — transparent background, no border/shadow for inline grouping
<Card variant="ghost">...</Card>
// Interactive — hover effects (lift + shadow + border) for clickable cards
<Card variant="interactive">...</Card>Variant Details
| Variant | Styles | Use When |
|---|---|---|
default | border-border/50 shadow-card | Standard content containers |
elevated | border-border/40 shadow-card-lg | Featured or highlighted content |
flat | border-border/60 shadow-none | Dense layouts, sidebars, tables |
ghost | border-transparent bg-transparent shadow-none | Semantic grouping without visual weight |
interactive | hover:shadow-card-lg hover:-translate-y-0.5 cursor-pointer | Clickable cards, links, selections |
Card Sub-Components
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description text</CardDescription>
<CardAction>
<Button variant="outline" size="sm">Action</Button>
</CardAction>
</CardHeader>
<CardContent>
{/* Main content */}
</CardContent>
<CardFooter>
{/* Footer actions */}
</CardFooter>
</Card>data-slot Selectors
[data-slot="card"] { /* root container */ }
[data-slot="card-header"] { /* header area */ }
[data-slot="card-title"] { /* title text */ }
[data-slot="card-description"] { /* description text */ }
[data-slot="card-action"] { /* action area (top-right) */ }
[data-slot="card-content"] { /* main content area */ }
[data-slot="card-footer"] { /* footer area */ }Common Patterns
Stat Card (Dashboard Metrics)
<Card>
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums">
$45,231.89
</CardTitle>
<CardAction>
<Badge variant="outline">+20.1%</Badge>
</CardAction>
</CardHeader>
<CardContent>
<div className="text-xs text-muted-foreground">
+$2,100 from last month
</div>
</CardContent>
</Card>Profile Card
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<Avatar>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>{user.initials}</AvatarFallback>
</Avatar>
<div>
<CardTitle>{user.name}</CardTitle>
<CardDescription>{user.role}</CardDescription>
</div>
</div>
<CardAction>
<Button variant="outline" size="sm">Edit</Button>
</CardAction>
</CardHeader>
<CardContent>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Email</span>
<span>{user.email}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Department</span>
<span>{user.department}</span>
</div>
</div>
</CardContent>
</Card>Product Card (Interactive)
<Card variant="interactive" className="overflow-hidden">
<div className="aspect-video bg-muted" />
<CardHeader>
<CardTitle>{product.name}</CardTitle>
<CardDescription>${product.price}</CardDescription>
<CardAction>
<Badge variant={product.inStock ? 'secondary' : 'destructive'}>
{product.inStock ? 'In Stock' : 'Out of Stock'}
</Badge>
</CardAction>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">
{product.description}
</p>
</CardContent>
<CardFooter>
<Button className="w-full">Add to Cart</Button>
</CardFooter>
</Card>Blog Post Card
<Card variant="interactive">
<CardHeader>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{post.category}</span>
<span>·</span>
<span>{post.readTime} min read</span>
</div>
<CardTitle className="text-lg">{post.title}</CardTitle>
<CardDescription>{post.excerpt}</CardDescription>
</CardHeader>
<CardFooter className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={post.author.avatar} />
<AvatarFallback>{post.author.initials}</AvatarFallback>
</Avatar>
<div className="text-sm">
<p className="font-medium">{post.author.name}</p>
<p className="text-muted-foreground">{post.date}</p>
</div>
</CardFooter>
</Card>Grid Layouts
3-Column Stats Grid
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardDescription>Total Users</CardDescription>
<CardTitle className="text-2xl tabular-nums">2,350</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription>Active Sessions</CardDescription>
<CardTitle className="text-2xl tabular-nums">1,247</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription>Revenue</CardDescription>
<CardTitle className="text-2xl tabular-nums">$45,231</CardTitle>
</CardHeader>
</Card>
</div>Dashboard Layout (Mixed Widths)
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Span 2 columns for featured card */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent>{/* Chart or main content */}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>{/* Activity list */}</CardContent>
</Card>
</div>Header/Footer Border Pattern
Add visual separation with Tailwind border utilities:
<Card>
<CardHeader className="border-b">
<CardTitle>Settings</CardTitle>
</CardHeader>
<CardContent className="pt-6">
{/* Form fields */}
</CardContent>
<CardFooter className="border-t pt-6">
<Button>Save Changes</Button>
</CardFooter>
</Card>TypeScript
import type { CardProps } from '@constructive-io/ui/card';
// CardProps extends React.ComponentProps<'div'> & VariantProps<typeof cardVariants>
// Available variants: 'default' | 'elevated' | 'flat' | 'ghost' | 'interactive'Combobox API Reference
Full type definitions and sub-component props for the Combobox system.
Combobox
Root component. Manages selection state and popup lifecycle.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | -- | Controlled single value |
defaultValue | string | -- | Uncontrolled single value |
onValueChange | (value: string) => void | -- | Callback on selection change |
multiple | boolean | false | Enable multiple selection mode |
open | boolean | -- | Controlled popup state |
onOpenChange | (open: boolean) => void | -- | Callback when popup opens/closes |
ComboboxInput
Text input for filtering items.
- Extends
React.ComponentProps<'input'> - Filters items as user types
- In multiple mode, sits after chips inside
ComboboxTrigger
ComboboxTrigger
Wrapper around input area. Shows chevron icon.
- In single mode: wraps
ComboboxInput - In multiple mode: wraps
ComboboxChips+ComboboxInput
ComboboxContent
Dropdown popup panel. Alias: ComboboxPopup.
| Prop | Type | Default | Description |
|---|---|---|---|
side | `'top' \ | 'bottom'` | 'bottom' |
sideOffset | number | -- | Distance from trigger in pixels |
align | `'start' \ | 'center' \ | 'end'` |
Uses useFloatingOverlayPortalProps() for correct z-index stacking inside modals/dialogs.
ComboboxList
Scrollable list container. Wraps ComboboxItem elements.
ComboboxItem
Individual selectable option.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | -- | Unique identifier (required) |
disabled | boolean | false | Prevents selection |
Shows check icon when selected (single mode) or checkbox indicator (multiple mode).
ComboboxGroup
Groups related items under a shared label.
Contains ComboboxGroupLabel followed by ComboboxItem elements.
ComboboxGroupLabel
Non-interactive header text for a ComboboxGroup. Rendered as styled text, not selectable.
ComboboxSeparator
Visual divider between groups or items. Renders as a horizontal rule.
ComboboxEmpty
Shown when no items match the current filter text.
<ComboboxEmpty>No results found</ComboboxEmpty>ComboboxValue
Displays the currently selected value(s) inside the trigger. Useful for custom display rendering.
ComboboxClear
Clear button to reset the entire selection back to empty.
ComboboxStatus
Accessibility status announcements for screen readers. Announces selection changes and filter results.
ComboboxRow
Advanced: row wrapper for virtualized list rendering. Use when displaying large option sets with virtual scrolling.
ComboboxCollection
Advanced: collection wrapper for virtualized rendering. Pairs with ComboboxRow.
Multiple Mode Components
ComboboxChips
Container for selected value chips in multiple mode. Renders as a flex-wrap container inside ComboboxTrigger.
<ComboboxTrigger>
<ComboboxChips>
{selected.map((val) => (
<ComboboxChip key={val} value={val}>
{val}
<ComboboxChipRemove />
</ComboboxChip>
))}
</ComboboxChips>
<ComboboxInput placeholder="Add more..." />
</ComboboxTrigger>ComboboxChip
Individual chip representing a selected value.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | -- | Which selected item this chip represents (required) |
ComboboxChipRemove
Remove button rendered inside a ComboboxChip. Clicking removes the corresponding value from the selection.
useComboboxFilter Hook
Client-side fuzzy filtering utility. Uses match-sorter internally.
import { useComboboxFilter } from '@constructive-io/ui/combobox';
const filteredItems = useComboboxFilter(allItems, inputValue, {
keys: ['label', 'value'], // Properties to match against
threshold: 0.3, // match-sorter threshold
});| Param | Type | Description |
|---|---|---|
items | T[] | Full list of items to filter |
inputValue | string | Current input text |
options.keys | string[] | Object keys to match against |
options.threshold | number | Match sensitivity (0-1, lower = stricter) |
Returns: filtered and ranked T[].
Note: This is client-side only. For server-side filtering, manage the items array yourself by fetching filtered results from your API and passing them directly to ComboboxItem elements.
Implementation Notes
- Built on
@base-ui/react/combobox - Portal rendering via
useFloatingOverlayPortalProps()ensures correct z-index inside modals - Keyboard navigation: arrow keys, Enter to select, Escape to close
- Type-ahead: items are filtered as user types, with debounced matching
- In multiple mode, Backspace in empty input removes the last selected chip
Command Palette Reference
Full command palette system from @constructive-io/command-palette — registry-based command management with keyboard shortcuts, multi-step wizards, and background tasks.
Package:@constructive-io/command-palette(standalone package inpackages/command-palette/)
UI primitives: @constructive-io/ui/command (cmdk-backed components)Quick Start
import {
CommandPalette,
CommandRegistryManager,
createCommandRegistry,
usePageCommands,
useBackgroundTasks,
BackgroundTaskStack,
kbd,
} from '@constructive-io/command-palette';
// 1. Create a registry with initial commands
const registry = createCommandRegistry({
groups: [
{ id: 'navigation', label: 'Navigate', priority: 1 },
{ id: 'actions', label: 'Actions', priority: 2 },
],
commands: [
{
id: 'go-home',
label: 'Go to Dashboard',
type: 'navigation',
group: 'navigation',
href: '/',
icon: Home,
shortcut: kbd('h', 'mod'),
keywords: ['home', 'main'],
},
],
});
// 2. Render the palette (Cmd+K by default)
function App() {
const router = useRouter();
const bgTasks = useBackgroundTasks();
return (
<>
<CommandPalette
registry={registry}
navigate={(href) => router.push(href)}
backgroundTasks={bgTasks}
/>
<BackgroundTaskStack
tasks={bgTasks.tasks}
onCancel={bgTasks.cancel}
onDismiss={bgTasks.dismiss}
/>
</>
);
}Type System
CommandDefinition
Every command in the palette is a CommandDefinition:
interface CommandDefinition {
id: string;
label: string;
description?: string;
icon?: React.ComponentType<{ className?: string }> | string;
shortcut?: KeyBinding; // Structured binding, NOT a string
type: CommandType;
group: string;
keywords?: string[];
href?: string; // For navigation/external types
external?: boolean; // Open in new tab
onSelect?: (signal?: AbortSignal) => void | Promise<void>;
background?: boolean; // Run as tracked background task
backgroundBehavior?: 'close' | 'reset' | 'persist' | ((controls: BackgroundPaletteControls) => void);
disabled?: boolean;
hidden?: boolean;
priority?: number; // Lower = higher in group (default: 99)
multiStep?: MultiStepConfig<any>;
}
type CommandType = 'navigation' | 'action' | 'search' | 'external' | 'multi-step';CommandGroupDef
interface CommandGroupDef {
id: string;
label: string;
priority: number; // Lower = appears first
}KeyBinding
Keyboard shortcuts use a structured type, not plain strings:
type KeyModifier = 'mod' | 'shift' | 'alt';
interface KeyBinding {
modifiers?: KeyModifier[];
key: string; // Lowercase: 'h', 'k', 'enter', 'backspace', ',', '/'
}
// Factory function
kbd('k', 'mod') // Cmd+K (Mac) / Ctrl+K (Win/Linux)
kbd('n', 'mod', 'shift') // Cmd+Shift+N
kbd('/') // Just /Keybinding Utilities
import { kbd, matchKeyBinding, formatKeyBinding, isMac, isEditableTarget } from '@constructive-io/command-palette';
// Match against a KeyboardEvent
matchKeyBinding(event, kbd('k', 'mod')) // true if Cmd+K pressed
// Format for display: ['⌘', 'K'] on Mac, ['Ctrl', 'K'] on PC
formatKeyBinding(kbd('k', 'mod'))
// Platform detection (SSR-safe, defaults to non-Mac)
isMac()
// Skip shortcuts when user is typing in an input/textarea
isEditableTarget(event.target)Registry
CommandRegistryManager
Central store for commands and groups with pub/sub for reactive UI updates. Uses cached snapshots for useSyncExternalStore compatibility.
import { CommandRegistryManager, createCommandRegistry } from '@constructive-io/command-palette';
// Create with initial data
const registry = createCommandRegistry({
groups: [{ id: 'nav', label: 'Navigate', priority: 1 }],
commands: [{ id: 'home', label: 'Home', type: 'navigation', group: 'nav', href: '/' }],
});
// Or create empty and populate dynamically
const registry = new CommandRegistryManager();
registry.registerGroup({ id: 'nav', label: 'Navigate', priority: 1 });
registry.registerCommand({ id: 'home', label: 'Home', type: 'navigation', group: 'nav', href: '/' });
// Unregister
registry.unregisterCommand('home');
registry.unregisterGroup('nav');
// Read (returns cached snapshot arrays)
registry.getCommands(); // CommandDefinition[]
registry.getGroups(); // CommandGroupDef[]
// Subscribe to changes
const unsub = registry.subscribe(() => console.log('registry changed'));Hooks
useCommandRegistry
Subscribe to registry changes with concurrent-mode safety (useSyncExternalStore):
import { useCommandRegistry } from '@constructive-io/command-palette';
function MyComponent({ registry }: { registry: CommandRegistryManager }) {
const { commands, groups } = useCommandRegistry(registry);
// Re-renders when commands/groups change
}usePageCommands
Register page-scoped commands that auto-cleanup on unmount:
import { usePageCommands } from '@constructive-io/command-palette';
function SettingsPage({ registry }: { registry: CommandRegistryManager }) {
// Memoize the commands array for stable references
const commands = useMemo(() => [
{
id: 'settings-reset',
label: 'Reset Settings',
type: 'action' as const,
group: 'actions',
onSelect: () => resetSettings(),
},
], []);
usePageCommands(registry, commands);
// Commands registered on mount, unregistered on unmount
}useCommandExecution
Handles all command types (navigation, action, external, multi-step, background):
import { useCommandExecution } from '@constructive-io/command-palette';
const { execute } = useCommandExecution(navigate, onMultiStepStart, backgroundTasks);
await execute(command);useGlobalShortcuts
Single document-level keydown listener for all command shortcuts. Skips editable targets:
import { useGlobalShortcuts } from '@constructive-io/command-palette';
// Typically used internally by CommandPalette, but can be used standalone
useGlobalShortcuts(commands, execute, enabled);CommandPalette Component
The main component wires together registry, shortcuts, multi-step, and background tasks:
interface CommandPaletteProps {
registry: CommandRegistryManager;
navigate?: NavigateAdapter; // e.g. router.push
open?: boolean; // Controlled open state
onOpenChange?: (open: boolean) => void;
shortcut?: KeyBinding; // Default: kbd('k', 'mod') = Cmd+K
placeholder?: string; // Default: 'Type a command or search...'
backgroundTasks?: UseBackgroundTasks;
}Adapter Strategy (Framework Independence)
// Next.js App Router
const router = useRouter();
<CommandPalette navigate={(href) => router.push(href)} />
// Plain browser
<CommandPalette navigate={(href) => window.location.assign(href)} />No Next.js imports exist in the command-palette package.
Multi-Step Commands
Wizard flows inside the palette with step-by-step data collection.
Builder API
import { multiStepCommand } from '@constructive-io/command-palette';
type WizardCtx = { name: string; template: string; confirmed: boolean };
const createProjectCmd = multiStepCommand<WizardCtx>({
id: 'create-project',
label: 'Create Project',
group: 'actions',
icon: FolderPlus,
})
.step({
id: 'name',
title: 'Project Name',
Component: NameStep,
})
.step({
id: 'template',
title: 'Choose Template',
Component: TemplateStep,
loader: async (ctx) => fetchTemplates(), // Async data loading
skippable: true,
})
.step({
id: 'confirm',
title: 'Confirm',
Component: ConfirmStep,
validate: (ctx) => ctx.name.length > 0 || 'Name is required',
})
.initialContext({ confirmed: false })
.onComplete(async (ctx) => {
await api.createProject(ctx);
})
.onCancel((ctx, stepIndex) => {
console.log(`Cancelled at step ${stepIndex}`);
})
.build();Step Component Props
Each step receives:
interface StepViewProps<TContext, TStepData = undefined> {
context: Readonly<TContext>; // Accumulated from previous steps
data: TStepData; // From this step's loader
onComplete: (output: Partial<TContext>) => void; // Merge into context & advance
onBack: () => void;
onSkip: () => void;
onError: (error: Error | string) => void;
status: StepStatus; // 'idle' | 'active' | 'loading' | 'error' | 'complete'
error: Error | null;
isFirst: boolean;
isLast: boolean;
stepIndex: number;
totalSteps: number;
}Step Definition
interface StepDefinition<TContext, TStepData = undefined> {
id: string;
title: string;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
Component: React.ComponentType<StepViewProps<TContext, TStepData>>;
loader?: (context: Readonly<TContext>) => Promise<TStepData>;
validate?: (context: Readonly<TContext>) => true | string;
skippable?: boolean;
}State Machine
Multi-step flows use a lightweight local state machine (no xstate dependency):
- Forward/backward animations via
motion/reactwith directional slides - Step indicator dots: complete = filled, active = filled + ring, error = destructive, idle = outline
- Loader effects run when a step has no cached data
- Completion effects run on last step
- Cancel aborts inflight loaders/completion
Background Tasks
Fire-and-forget command dispatch with tracking, cancellation, and auto-dismiss.
useBackgroundTasks Hook
import { useBackgroundTasks } from '@constructive-io/command-palette';
const bgTasks = useBackgroundTasks({
onTaskChange: (task) => {
if (task.status === 'error') showErrorToast(`${task.label} failed`);
},
successDismissMs: 5000, // Auto-dismiss success after 5s
cancelledDismissMs: 3000, // Auto-dismiss cancelled after 3s
});
// bgTasks.tasks — sorted: running first, then by completedAt desc
// bgTasks.dispatch — start a background task
// bgTasks.cancel — abort via AbortController
// bgTasks.dismiss — remove non-running tasks
// bgTasks.dismissCompleted — remove all completed tasksBackground Command Definition
{
id: 'export-csv',
label: 'Export as CSV',
type: 'action',
group: 'data',
background: true,
backgroundBehavior: 'close', // or 'reset', 'persist', or callback
onSelect: async (signal) => {
const blob = await api.exportCsv({ signal });
downloadBlob(blob, 'data.csv');
},
}Background Task Components
BackgroundTaskStack — floating toast-style stack (bottom-right):
<BackgroundTaskStack
tasks={bgTasks.tasks}
onCancel={bgTasks.cancel}
onDismiss={bgTasks.dismiss}
/>InlineTaskBar — compact inline indicator inside the palette (rendered automatically by CommandPalette when backgroundTasks prop is provided).
Dual-Mode Rendering
- Palette open:
InlineTaskBarrenders betweenCommandPanelandCommandFooter - Palette closed:
BackgroundTaskStackrenders as a floating stack (consumer places it)
KbdShortcut Component
Renders a KeyBinding as individual <kbd> elements (Raycast-style):
import { KbdShortcut } from '@constructive-io/command-palette';
<KbdShortcut binding={kbd('k', 'mod')} />
// Renders: [⌘] [K] on Mac, [Ctrl] [K] on PCUI Primitives (from @constructive-io/ui/command)
The palette renders using cmdk-backed components from the UI library:
import {
CommandDialog,
CommandDialogPopup,
CommandInput,
CommandList,
CommandGroup,
CommandItem,
CommandSeparator,
CommandFooter,
CommandPanel,
CommandEmpty,
CommandShortcut,
CommandGroupLabel,
CommandCollection,
Command,
} from '@constructive-io/ui/command';Key behaviors:
onSelectonCommandItemfires on both click and Enter- Direct children pattern (no render-function callbacks, no
itemsprop) - Built-in filtering against children textContent +
keywordsprop data-slotattributes for styling hooks
data-slot Selectors
[data-slot="command-input"] { /* search input */ }
[data-slot="command-list"] { /* scrollable list */ }
[data-slot="command-group"] { /* group container */ }
[data-slot="command-group-label"] { /* group heading */ }
[data-slot="command-item"] { /* individual item */ }
[data-slot="command-shortcut"] { /* keyboard shortcut */ }
[data-slot="command-footer"] { /* footer area */ }
[data-slot="command-empty"] { /* empty state */ }
[data-slot="inline-task-bar"] { /* background tasks inline bar */ }Best Practices
1. Organize by intent — group commands by user goal (Navigate, Create, Settings) 2. Use clear labels — "Go to Dashboard" > "Dashboard" 3. Add keywords — include synonyms and related terms for search 4. Limit shortcuts — only assign to frequently-used commands 5. Show descriptions — add for complex or ambiguous commands 6. Context awareness — use usePageCommands for page-scoped commands 7. Background for slow ops — use background: true for exports, syncs, uploads 8. Provide feedback — use onTaskChange to fire toasts on completion/failure 9. Memoize command arrays — pass stable references to usePageCommands
Constructive UI
Build UIs with @constructive-io/ui — 50+ components on Base UI + Tailwind CSS v4.
When to Apply
- Building UIs with
@constructive-io/uicomponents - Creating custom components with cva/cn/data-slot
- Setting up theming, dark mode, OKLCH tokens
- Building forms, overlays, layouts, advanced inputs
- Using the shadcn registry
- Adding animations with motion/react
Quick Start
Deep Import Convention
// Correct — tree-shakeable
import { Button } from '@constructive-io/ui/button';
import { Dialog, DialogTrigger, DialogPopup } from '@constructive-io/ui/dialog';
import { cn } from '@constructive-io/ui/lib/utils';
// Avoid — barrel import pulls entire library
import { Button } from '@constructive-io/ui';PortalRoot Setup (Required)
All overlay components (dialogs, popovers, tooltips, sheets) require PortalRoot in your root layout:
import { PortalRoot } from '@constructive-io/ui/portal';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<PortalRoot />
</body>
</html>
);
}Toaster Setup
Add Toaster to your root layout for toast notifications:
import { Toaster } from '@constructive-io/ui/sonner';
// Place <Toaster /> alongside <PortalRoot /> in your bodyComponent Architecture
Every component follows: cva for variant definitions, cn() for class merging, data-slot on root, named exports.
'use client';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@constructive-io/ui/lib/utils';
const myVariants = cva('base-classes', {
variants: { variant: { default: '...', secondary: '...' } },
defaultVariants: { variant: 'default' },
});
type MyProps = React.ComponentProps<'div'> & VariantProps<typeof myVariants>;
function MyComponent({ className, variant, ...props }: MyProps) {
return <div data-slot="my-component" className={cn(myVariants({ variant }), className)} {...props} />;
}
export { MyComponent, myVariants, type MyProps };Key rules:
React.ComponentProps<'element'>overReact.HTMLAttributes- Always set
data-sloton root element cn()to merge className — never concatenate- Named exports only,
'use client'when using hooks/events Slot+Slottablefrom@constructive-io/ui/lib/utilsfor polymorphicasChild
See references/foundations.md for full patterns, Base UI mapping, TypeScript conventions.
Theming & Tokens
OKLCH token system in globals.css with @theme inline for Tailwind v4.
Key Tokens
| Category | Tokens |
|---|---|
| Surface | background, foreground, card, popover |
| Interactive | primary, secondary, accent, muted, destructive |
| Status | info, success, warning |
| Input | border, input, ring |
| Sidebar | sidebar, sidebar-primary, sidebar-accent, sidebar-border |
| Chart | chart-1 through chart-5 |
Dark Mode
Class-based via .dark on <html>. Tailwind v4 directive: @custom-variant dark (&:is(.dark *));
// Toggle implementation
document.documentElement.classList.toggle('dark', isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');Tailwind v4 Migration
| v3 | v4 |
|---|---|
shadow-sm | shadow-xs |
shadow | shadow-sm |
rounded-sm | rounded-xs |
rounded | rounded-sm |
outline-none | outline-hidden |
bg-opacity-* | bg-black/50 |
bg-[--brand] | bg-(--brand) |
See references/theming.md for complete globals.css, z-index layers, shadow utilities. See references/token-values.md for all light/dark OKLCH values.
Registry Installation
Install components via shadcn registry or npm.
// components.json — add constructive registry
{
"registries": {
"@constructive": "https://constructive-io.github.io/dashboard/r/{name}.json"
}
}npx shadcn@latest add @constructive/button
npx shadcn@latest add @constructive/form-kit # all form components
npx shadcn@latest add @constructive/overlay-kit # all overlay components
npx shadcn@latest add @constructive/layout-kit # all layout components
npx shadcn@latest add @constructive/constructive-theme # full token systemnpm = centralized updates, version-locked. Registry = source ownership, deep customization.
See references/registry.md for full component list, bundles, build pipeline.
Animation System
Import from motion/react (NOT framer-motion). Use presets from @constructive-io/ui/lib/motion/motion-config.
| Preset | Use For |
|---|---|
variants.fadeScale | Modal/dialog enter |
variants.fadeSlideUp | List items with stagger |
variants.fadeSlideDown | Toast/notification |
variants.fade | Subtle presence change |
variants.floatUp | Hero entrance |
transitions.panel | Sheet/drawer slide |
springs.snappy | Button press (whileTap) |
transitions.enterExit | Tab/route transitions |
'use client';
import { motion, AnimatePresence } from 'motion/react';
import { variants } from '@constructive-io/ui/lib/motion/motion-config';
<AnimatePresence mode="wait">
{isOpen && (
<motion.div key="panel" variants={variants.fadeScale} initial="initial" animate="animate" exit="exit">
{children}
</motion.div>
)}
</AnimatePresence>See references/motion.md for all presets, stagger patterns, reduced motion, performance rules.
Forms
Three layers: Field (standalone labels), FormControl (floating labels), Form (react-hook-form).
// Layer 1: Field — simple label + input
import { Field } from '@constructive-io/ui/field';
import { Input } from '@constructive-io/ui/input';
<Field label="Email" error={errors.email} required>
<Input type="email" placeholder="name@example.com" />
</Field>
// Layer 3: Form — react-hook-form integration
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@constructive-io/ui/form';
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
</form>
</Form>InputGroup for addons: InputGroupAddon with position="inline-start|inline-end|block-start|block-end".
See references/forms.md for all form patterns, Zod validation, composition examples. See references/input-components.md for Input, Textarea, Checkbox, RadioGroup, Switch, Select API.
Overlays
| Component | Use When |
|---|---|
| Dialog | Modal form, confirmation, focused content |
| AlertDialog | Destructive confirmation (blocks interaction) |
| Sheet | Side panel for details, editing |
| Popover | Contextual info/controls, filter panels |
| Tooltip | Brief hints on hover/focus |
| DropdownMenu | Action menus, context menus |
| Command | Command palette (Cmd+K), search-driven command execution |
// Dialog
import { Dialog, DialogTrigger, DialogPopup, DialogHeader, DialogTitle, DialogFooter } from '@constructive-io/ui/dialog';
<Dialog>
<DialogTrigger asChild><Button>Open</Button></DialogTrigger>
<DialogPopup>
<DialogHeader><DialogTitle>Title</DialogTitle></DialogHeader>
{/* content */}
<DialogFooter><Button>Save</Button></DialogFooter>
</DialogPopup>
</Dialog>
// Sheet
import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle } from '@constructive-io/ui/sheet';
<Sheet>
<SheetTrigger asChild><Button>Open Panel</Button></SheetTrigger>
<SheetContent side="right">{/* content */}</SheetContent>
</Sheet>Floating elements inside modals auto-elevate z-index via useFloatingOverlayPortalProps().
See references/overlays.md for all overlay components, nesting patterns. See references/sheet-stacking.md for SheetStackProvider deep dive. See references/dropdown-menu-api.md for DropdownMenu sub-component API. See references/command-palette.md for full command palette system — registry, hooks, multi-step wizards, background tasks, keyboard shortcuts.
Layout & Navigation
// Sidebar — full app shell
import { SidebarProvider, Sidebar, SidebarContent, SidebarMenu, SidebarMenuItem,
SidebarMenuButton, SidebarInset, SidebarTrigger } from '@constructive-io/ui/sidebar';
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton isActive tooltip="Home"><Home className="size-4" /><span>Home</span></SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarContent>
</Sidebar>
<SidebarInset>{/* main content */}</SidebarInset>
</SidebarProvider>
// Tabs
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@constructive-io/ui/tabs';
<Tabs defaultValue="general">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
</TabsList>
<TabsContent value="general">...</TabsContent>
<TabsContent value="security">...</TabsContent>
</Tabs>Also: Breadcrumb, Pagination, Stepper, Collapsible, Resizable, ScrollArea, PageHeader, Dock.
Stack Navigation (iOS-Style Card Navigation)
The primary navigation pattern in the Constructive admin app. Cards push/pop from the right with peek interactions, gestures, and responsive layout.
import { CardStackProvider, useCardStack, CardStackViewport } from '@constructive-io/ui/stack';
// Root layout — wraps entire app
<CardStackProvider layoutMode="side-by-side" defaultPeekOffset={48}>
{children}
<ClientOnlyStackViewport />
</CardStackProvider>
// Push cards imperatively
const stack = useCardStack();
stack.push({ title: 'Profile', Component: ProfileCard, props: { userId } });Layout modes: cascade (overlapping peek) and side-by-side (master-detail). Cards support useCardReady() to defer queries until slide animation completes.
See references/stack-navigation.md for full Stack API — CardSpec, CardStackApi, route registry, peek gestures, mobile behavior.
See references/layout.md for all layout components. See references/sidebar-api.md for Sidebar sub-component props, CSS variables.
Data Display & Feedback
// Badge variants
import { Badge } from '@constructive-io/ui/badge';
<Badge variant="success">Active</Badge>
<Badge variant="destructive">Error</Badge>
<Badge variant="warning">Pending</Badge>
// Table
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@constructive-io/ui/table';
// Toast
import { showSuccessToast, showErrorToast } from '@constructive-io/ui/toast';
showSuccessToast('Saved');
showErrorToast('Failed', 'Please try again');Also: Alert, Avatar, Skeleton, Progress, FlickeringGrid, MotionGrid, ProgressiveBlur.
See references/data-display.md for all data display components.
Advanced Inputs
| Need | Component |
|---|---|
| Search + select one | Autocomplete (simple) or Combobox (richer) |
| Search + select multiple | Combobox multiple or MultiSelect |
| Tag-style multi-picker | Tags (create-on-enter) |
| Link/unlink records | RecordPicker (fuzzy search, checkboxes) |
| Date selection | Calendar / RangeCalendar |
| JSON editing | JsonInput (with validation) |
// Combobox
import { Combobox, ComboboxInput, ComboboxTrigger, ComboboxContent, ComboboxItem, ComboboxList } from '@constructive-io/ui/combobox';
<Combobox value={value} onValueChange={setValue}>
<ComboboxTrigger><ComboboxInput placeholder="Select..." /></ComboboxTrigger>
<ComboboxContent>
<ComboboxList>
{items.map((item) => <ComboboxItem key={item.value} value={item.value}>{item.label}</ComboboxItem>)}
</ComboboxList>
</ComboboxContent>
</Combobox>See references/advanced-inputs.md for all advanced input components. See references/combobox-api.md for Combobox sub-component props, multiple mode, useComboboxFilter.
Component Catalog
Primitives
button, badge, label, skeleton, card (patterns), separator, alert
Form
input, textarea, checkbox, checkbox-group, radio-group, switch, select, progress, form, form-control, input-group, field
Overlay
dialog, alert-dialog, sheet, drawer, popover, tooltip, dropdown-menu, command
Layout
tabs, collapsible, scroll-area, resizable, sidebar, breadcrumb, pagination, stepper, page-header, dock
Data
table, avatar
Advanced Inputs
autocomplete, combobox, multi-select, tags, record-picker, calendar-rac, json-input
Notifications
sonner, toast
Navigation
stack (full reference)
Utilities
portal, lib/utils (cn, Slot, Slottable, composeRefs, mergeProps, useControllableState), lib/motion/motion-config, globals.css
Effects
flickering-grid, motion-grid, progressive-blur, progressive-blur-scroll-container, responsive-diagram
All imported via @constructive-io/ui/{name}.
Best Practices
- Imports: Always use deep imports (
@constructive-io/ui/button), never barrel imports - Components: Named exports only,
data-sloton root,cn()for class merging,...propsspread - Types:
React.ComponentProps<'element'>overReact.HTMLAttributes - Client:
'use client'on any component using hooks, events, or browser APIs - Tailwind v4:
shadow-xsnotshadow-sm,rounded-xsnotrounded-sm,bg-black/50notbg-opacity-* - Tokens: Use semantic tokens (
bg-primary) not raw colors (bg-blue-500), define light+dark for custom tokens - Z-index: Use layer variables (
--z-layer-floating), never hardcode values - Animation: Use
motion-configpresets, respectprefers-reduced-motion - Forms:
Fieldfor simple forms,Form+FormFieldfor validation,zodResolverover inline rules - Overlays:
asChildon triggers,PortalRootrequired,AlertDialogfor destructive confirms - Icons:
size-4shorthand overw-4 h-4
References
- references/foundations.md — Component architecture: cva, cn, Slot, data-slot, Base UI mapping
- references/theming.md — OKLCH tokens, globals.css, dark mode, z-index layers, Tailwind v4 migration
- references/token-values.md — Complete light/dark OKLCH token value table
- references/registry.md — shadcn registry setup, npm vs registry, bundles, troubleshooting
- references/motion.md — motion/react presets, AnimatePresence, springs, reduced motion
- references/forms.md — Field, FormControl, Form (react-hook-form), InputGroup patterns
- references/input-components.md — Input, Textarea, Checkbox, RadioGroup, Switch, Select API
- references/overlays.md — Dialog, AlertDialog, Sheet, Popover, Tooltip, DropdownMenu
- references/sheet-stacking.md — SheetStackProvider modes, useSheetStack, nested sheets
- references/dropdown-menu-api.md — DropdownMenu sub-component props reference
- references/layout.md — Sidebar, Tabs, Breadcrumb, Pagination, Stepper, Collapsible, Resizable
- references/sidebar-api.md — Sidebar sub-component props, CSS variables, cookie persistence
- references/data-display.md — Table, Badge, Alert, Avatar, Skeleton, Progress, Toast, effects
- references/advanced-inputs.md — Autocomplete, Combobox, MultiSelect, Tags, RecordPicker, Calendar, JsonInput
- references/combobox-api.md — Combobox sub-component props, multiple mode, useComboboxFilter
- references/command-palette.md — Full command palette system: registry, hooks, multi-step wizards, background tasks, keyboard shortcuts
- references/card-patterns.md — Card variants (default/elevated/flat/ghost/interactive), usage patterns, grid layouts
- references/stack-navigation.md — iOS-style card navigation: CardStackApi, route registry, peek gestures, mobile behavior
constructive-ui-data-display
Data display and feedback components from @constructive-io/ui.
Table
Full hierarchy: Table > TableHeader/TableBody/TableFooter > TableRow > TableHead/TableCell. Optional TableCaption.
import {
Table,
TableHeader,
TableBody,
TableFooter,
TableRow,
TableHead,
TableCell,
TableCaption,
} from '@constructive-io/ui/table';
function UsersTable({ users }: { users: User[] }) {
return (
<Table>
<TableCaption>Active team members</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Name</TableHead>
<TableHead>Email</TableHead>
<TableHead className="text-right">Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell className="text-right">{user.role}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}Table Styling Patterns
Table is unstyled by default. Apply your own patterns:
| Pattern | How |
|---|---|
| Striped rows | even:bg-muted/50 on TableRow |
| Bordered | border on Table, border-b on TableRow |
| Compact | [&_td]:py-1 [&_th]:py-1 on Table |
| Hover highlight | hover:bg-muted/50 on TableRow |
| Fixed header | Wrap in scrollable container, sticky top-0 bg-background on TableHeader |
Table with Footer Totals
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell className="text-right">${item.amount.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-medium">Total</TableCell>
<TableCell className="text-right font-medium">${total.toFixed(2)}</TableCell>
</TableRow>
</TableFooter>
</Table>Badge
Semantic status indicators with size variants.
import { Badge } from '@constructive-io/ui/badge';Variants
<Badge variant="default">Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="error">Error</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="info">Info</Badge>
<Badge variant="success">Success</Badge>Sizes
<Badge size="sm">Small</Badge>
<Badge size="default">Default</Badge>
<Badge size="lg">Large</Badge>Badge with Dot Indicator
function StatusBadge({ status }: { status: 'active' | 'inactive' | 'pending' }) {
const config = {
active: { variant: 'success' as const, label: 'Active' },
inactive: { variant: 'secondary' as const, label: 'Inactive' },
pending: { variant: 'warning' as const, label: 'Pending' },
};
const { variant, label } = config[status];
return (
<Badge variant={variant} size="sm">
<span className="mr-1 inline-block size-1.5 rounded-full bg-current" />
{label}
</Badge>
);
}Alert
Banners for notices, warnings, and errors. Icon is positioned absolutely — use pl-7 when an icon is present.
import { Alert, AlertTitle, AlertDescription } from '@constructive-io/ui/alert';
import { AlertCircle, CheckCircle2, Info, TriangleAlert } from 'lucide-react';Variants
// Success
<Alert variant="default">
<CheckCircle2 className="size-4" />
<AlertTitle>Success</AlertTitle>
<AlertDescription>Your changes have been saved.</AlertDescription>
</Alert>
// Error
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Something went wrong. Please try again.</AlertDescription>
</Alert>
// Informational
<Alert variant="default">
<Info className="size-4" />
<AlertTitle>Note</AlertTitle>
<AlertDescription>This action cannot be undone.</AlertDescription>
</Alert>Alert without Title
<Alert variant="default">
<Info className="size-4" />
<AlertDescription>Your session will expire in 5 minutes.</AlertDescription>
</Alert>Avatar
Image with fallback to initials.
import { Avatar, AvatarImage, AvatarFallback } from '@constructive-io/ui/avatar';Basic Usage
<Avatar>
<AvatarImage src="/avatars/user.jpg" alt="Jane Doe" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>Initials Helper
Extract 2-letter initials from a name:
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}Avatar Group
function AvatarGroup({ users }: { users: { name: string; avatar?: string }[] }) {
const visible = users.slice(0, 4);
const remaining = users.length - visible.length;
return (
<div className="flex -space-x-2">
{visible.map((user) => (
<Avatar key={user.name} className="size-8 border-2 border-background">
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
<AvatarFallback className="text-xs">{getInitials(user.name)}</AvatarFallback>
</Avatar>
))}
{remaining > 0 && (
<Avatar className="size-8 border-2 border-background">
<AvatarFallback className="text-xs">+{remaining}</AvatarFallback>
</Avatar>
)}
</div>
);
}Skeleton
Animated loading placeholders. Match dimensions to loaded content to prevent layout shift.
import { Skeleton } from '@constructive-io/ui/skeleton';Card Skeleton
<div className="flex flex-col gap-3">
<Skeleton className="h-[200px] w-full rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>Table Row Skeleton
<TableRow>
<TableCell><Skeleton className="h-4 w-[150px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[200px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
</TableRow>Form Skeleton
<div className="space-y-4">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-4 w-[120px]" />
<Skeleton className="h-10 w-full" />
</div>Progress
Determinate (with value) and indeterminate (no value) progress bars.
import { Progress } from '@constructive-io/ui/progress';
// Determinate
<Progress value={66} />
<Progress value={100} />
// Indeterminate (animated)
<Progress />Progress with Label
function LabeledProgress({ value, label }: { value: number; label: string }) {
return (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>{label}</span>
<span className="text-muted-foreground">{value}%</span>
</div>
<Progress value={value} />
</div>
);
}Toast / Sonner
Toast notification system using sonner.
Layout Setup (Once)
Add Toaster to your root layout:
import { Toaster } from '@constructive-io/ui/sonner';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<Toaster />
</body>
</html>
);
}Basic Toasts
import { toast } from 'sonner';
toast.success('Saved successfully');
toast.error('Failed to save');
toast.warning('Disk space low');
toast.info('New update available');Styled Variants with Icons
import {
showErrorToast,
showSuccessToast,
showWarningToast,
showInfoToast,
} from '@constructive-io/ui/toast';
showSuccessToast('Changes saved');
showErrorToast('Operation failed', 'Please try again');
showWarningToast('Approaching limit');
showInfoToast('Tip: Use keyboard shortcuts');Toast with Action
toast('File deleted', {
action: {
label: 'Undo',
onClick: () => restoreFile(fileId),
},
});Promise Toast
toast.promise(saveData(), {
loading: 'Saving...',
success: 'Data saved',
error: 'Failed to save',
});Visual Effects
Decorative animated backgrounds and scroll effects for landing pages and feature sections.
FlickeringGrid
import { FlickeringGrid } from '@constructive-io/ui/flickering-grid';
<div className="relative h-[400px]">
<FlickeringGrid className="absolute inset-0" />
<div className="relative z-10">Content on top</div>
</div>MotionGrid
import { MotionGrid } from '@constructive-io/ui/motion-grid';
<div className="relative overflow-hidden rounded-lg">
<MotionGrid className="absolute inset-0 opacity-30" />
<div className="relative z-10 p-8">Overlay content</div>
</div>ProgressiveBlur
Fade-out effect at scroll edges:
import { ProgressiveBlur } from '@constructive-io/ui/progressive-blur';
<div className="relative">
<div className="h-[300px] overflow-auto">
{/* Scrollable content */}
</div>
<ProgressiveBlur className="pointer-events-none absolute bottom-0 h-20 w-full" />
</div>ProgressiveBlurScrollContainer
Wraps children with automatic blur at scroll edges:
import { ProgressiveBlurScrollContainer } from '@constructive-io/ui/progressive-blur';
<ProgressiveBlurScrollContainer className="h-[400px]">
{longContent}
</ProgressiveBlurScrollContainer>ResponsiveDiagram
Auto-scales diagram content to fit its container:
import { ResponsiveDiagram } from '@constructive-io/ui/responsive-diagram';
<ResponsiveDiagram className="h-[300px] w-full">
<svg viewBox="0 0 800 600">{/* Diagram content */}</svg>
</ResponsiveDiagram>Component Quick Reference
| Component | Import Path | Key Props |
|---|---|---|
| Table family | @constructive-io/ui/table | Standard HTML table semantics |
| Badge | @constructive-io/ui/badge | variant, size |
| Alert family | @constructive-io/ui/alert | variant (default, destructive) |
| Avatar family | @constructive-io/ui/avatar | src, alt, fallback children |
| Skeleton | @constructive-io/ui/skeleton | Dimensions via className |
| Progress | @constructive-io/ui/progress | value (omit for indeterminate) |
| Toaster | @constructive-io/ui/sonner | Layout-level setup |
| Toast helpers | @constructive-io/ui/toast | showSuccessToast, etc. |
| FlickeringGrid | @constructive-io/ui/flickering-grid | Decorative background |
| MotionGrid | @constructive-io/ui/motion-grid | Animated background |
| ProgressiveBlur | @constructive-io/ui/progressive-blur | Scroll fade effect |
| ResponsiveDiagram | @constructive-io/ui/responsive-diagram | Auto-scaling container |
Best Practices
- Use semantic Badge variants (
success,error,warning,info) over custom colors for consistent meaning - Skeleton dimensions should match the loaded content to avoid layout shift — use fractional widths (
w-3/4,w-1/2) to mimic text - Always provide
alttext for Avatar images; use 2-letter initials for the fallback - Set up
Toasteronce in root layout, then calltoastfunctions from anywhere in the app - Table is unstyled by default — apply striping, borders, and hover patterns to match your design
- Prefer
showSuccessToast/showErrorToasthelpers over rawtoast.success/toast.errorfor consistent styling with icons - Use
toast.promisefor async operations to show loading/success/error states automatically - Place visual effects (
FlickeringGrid,MotionGrid) behind content withabsolute inset-0andz-10on the foreground - Keep Alert messages concise — use
AlertDescriptionfor details,AlertTitlefor the headline
DropdownMenu API Reference
Complete sub-component API for the @constructive-io/ui dropdown menu system. Built on @base-ui/react/menu.
DropdownMenu
Root component. Manages open/close state.
Props:
open?: boolean-- controlled open stateonOpenChange?: (open: boolean) => void-- state change handler
// Uncontrolled
<DropdownMenu>...</DropdownMenu>
// Controlled
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>...</DropdownMenu>DropdownMenuTrigger
Element that toggles the menu.
Props:
asChild?: boolean-- merge props into child element instead of rendering a wrapper
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>DropdownMenuPortal
Optional portal wrapper. Used internally by default -- rarely needed explicitly.
DropdownMenuContent
The popup panel containing menu items.
Props:
side?: 'top' | 'right' | 'bottom' | 'left'-- placement relative to trigger (default:'bottom')sideOffset?: number-- distance from trigger in pixels (default:4)align?: 'start' | 'center' | 'end'-- alignment along the side axis (default:'center')alignOffset?: number-- offset along the alignment axis
<DropdownMenuContent align="end" sideOffset={8}>
{/* Menu items */}
</DropdownMenuContent>DropdownMenuGroup
Groups related items visually. No semantic props beyond standard div attributes.
<DropdownMenuGroup>
<DropdownMenuItem>Cut</DropdownMenuItem>
<DropdownMenuItem>Copy</DropdownMenuItem>
<DropdownMenuItem>Paste</DropdownMenuItem>
</DropdownMenuGroup>DropdownMenuItem
Clickable menu item.
Props:
variant?: 'default' | 'destructive'-- visual styledisabled?: boolean-- prevents interactiononSelect?: () => void-- called when item is selected
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
<DropdownMenuItem disabled>Archive</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onSelect={handleDelete}>Delete</DropdownMenuItem>DropdownMenuCheckboxItem
Menu item with a checkbox indicator.
Props:
checked?: boolean-- controlled checked stateonCheckedChange?: (checked: boolean) => void-- state change handler
<DropdownMenuCheckboxItem checked={showGrid} onCheckedChange={setShowGrid}>
Show Grid
</DropdownMenuCheckboxItem>DropdownMenuRadioGroup
Container for mutually exclusive radio items.
Props:
value?: string-- controlled selected valueonValueChange?: (value: string) => void-- selection change handler
<DropdownMenuRadioGroup value={sortBy} onValueChange={setSortBy}>
<DropdownMenuRadioItem value="name">Name</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="date">Date</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="size">Size</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>DropdownMenuRadioItem
Radio option within a radio group.
Props:
value: string-- the value this option represents
DropdownMenuLabel
Non-interactive group label.
Props:
inset?: boolean-- adds left padding to align with items that have icons
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuLabel inset>More Actions</DropdownMenuLabel>DropdownMenuSeparator
Visual divider between groups of items.
<DropdownMenuSeparator />DropdownMenuShortcut
Keyboard shortcut display. Renders as <span> with muted, right-aligned styling.
<DropdownMenuItem>
Save <DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
</DropdownMenuItem>DropdownMenuSub
Sub-menu root. Nests inside DropdownMenuContent.
<DropdownMenuSub>
<DropdownMenuSubTrigger>Share</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Email</DropdownMenuItem>
<DropdownMenuItem>Slack</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>DropdownMenuSubTrigger
Opens the sub-menu on hover/focus. Renders a chevron indicator.
Props:
inset?: boolean-- adds left padding for icon alignment
DropdownMenuSubContent
Sub-menu popup panel. Accepts the same positioning props as DropdownMenuContent.
Z-Index Handling
Uses useFloatingOverlayPortalProps() internally for correct z-index layering. When rendered inside a modal (Dialog, Sheet), the dropdown automatically receives z-[var(--z-layer-floating-elevated)] to appear above the modal content.
Build forms using @constructive-io/ui form components with three architectural layers.
Three-Layer Architecture
Layer 1: Field (standalone, no form library)
Field-- vertical layout: label above control, description below, error at bottomFieldRow-- horizontal layout: control beside label (for checkboxes, switches)
Layer 2: FormControl (standalone, layout wrapper)
- Two modes:
stacked(label above input) andfloating(CSS floating label) - Uses
Slotto inject id, aria-invalid, aria-describedby into child - No form library dependency
Layer 3: Form (react-hook-form integration)
Form=FormProviderfrom react-hook-formFormFieldwraps RHFControllerFormItem,FormLabel,FormControl,FormDescription,FormMessageuseFormFieldhook for accessing field state
Decision Guide
| Need | Layer | Components |
|---|---|---|
| Simple label + input | Field | Field, FieldRow |
| Floating labels, stacked layout | FormControl | FormControl |
| Form validation + submission | Form | Form, FormField, FormItem, FormLabel, FormMessage |
| Input addons (icons, buttons) | InputGroup | InputGroup, InputGroupAddon |
Field Component
import { Field, FieldRow } from '@constructive-io/ui/field';
import { Input } from '@constructive-io/ui/input';
import { Checkbox } from '@constructive-io/ui/checkbox';
// Vertical field
<Field label="Email" description="We'll never share your email" error={errors.email} required>
<Input type="email" placeholder="name@example.com" />
</Field>
// Horizontal field row (for toggles/checkboxes)
<FieldRow label="Accept terms" description="Required to continue">
<Checkbox />
</FieldRow>Props: label: string, description?: string, error?: string, required?: boolean, htmlFor?: string
InputGroup Component
import { InputGroup, InputGroupAddon, InputGroupText, InputGroupInput } from '@constructive-io/ui/input-group';
import { Mail, Search, DollarSign } from 'lucide-react';
import { Button } from '@constructive-io/ui/button';
// Icon addon
<InputGroup>
<InputGroupAddon position="inline-start">
<Mail className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput placeholder="Email address" />
</InputGroup>
// Text addon
<InputGroup>
<InputGroupAddon position="inline-start">
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput placeholder="example.com" />
<InputGroupAddon position="inline-end">
<InputGroupText>.com</InputGroupText>
</InputGroupAddon>
</InputGroup>
// Button addon
<InputGroup>
<InputGroupInput placeholder="Search..." />
<InputGroupAddon position="inline-end">
<Button size="sm" variant="ghost"><Search className="size-4" /></Button>
</InputGroupAddon>
</InputGroup>
// Block addons (above/below)
<InputGroup>
<InputGroupAddon position="block-start">
<span className="text-sm text-muted-foreground">Label above</span>
</InputGroupAddon>
<InputGroupInput placeholder="Value" />
<InputGroupAddon position="block-end">
<span className="text-xs text-muted-foreground">Helper text below</span>
</InputGroupAddon>
</InputGroup>Addon positions: inline-start (left), inline-end (right), block-start (above), block-end (below). Uses :has() CSS selectors for coordinated focus/error states.
FormControl Component (Floating Label)
import { FormControl } from '@constructive-io/ui/form-control';
import { Input } from '@constructive-io/ui/input';
// Stacked layout (default)
<FormControl label="Username" error="Username is required">
<Input placeholder="Enter username" />
</FormControl>
// Floating label
<FormControl label="Email" layout="floating">
<Input placeholder=" " />
</FormControl>The floating label works by targeting placeholder=" " -- the label lifts when input has focus or value. Props: label, description?, error?, layout?: 'stacked' | 'floating', required?.
Form (React Hook Form Integration)
'use client';
import { useForm } from 'react-hook-form';
import {
Form, FormField, FormItem, FormLabel, FormControl,
FormDescription, FormMessage,
} from '@constructive-io/ui/form';
import { Input } from '@constructive-io/ui/input';
import { Button } from '@constructive-io/ui/button';
type LoginForm = { email: string; password: string };
function LoginForm() {
const form = useForm<LoginForm>({
defaultValues: { email: '', password: '' },
});
function onSubmit(data: LoginForm) {
console.log(data);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
rules={{ required: 'Email is required' }}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="name@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
rules={{ required: 'Password is required', minLength: { value: 8, message: 'Min 8 chars' } }}
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>Minimum 8 characters</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Sign in</Button>
</form>
</Form>
);
}Complete Settings Form Example
'use client';
import { useForm } from 'react-hook-form';
import {
Form, FormField, FormItem, FormLabel, FormControl, FormMessage,
} from '@constructive-io/ui/form';
import { Input } from '@constructive-io/ui/input';
import { Textarea } from '@constructive-io/ui/textarea';
import { Switch } from '@constructive-io/ui/switch';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@constructive-io/ui/select';
import { Button } from '@constructive-io/ui/button';
type SettingsForm = {
displayName: string;
bio: string;
notifications: boolean;
theme: string;
};
function SettingsForm() {
const form = useForm<SettingsForm>({
defaultValues: { displayName: '', bio: '', notifications: true, theme: 'system' },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(console.log)} className="space-y-6">
<FormField control={form.control} name="displayName" render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="bio" render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl><Textarea {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="notifications" render={({ field }) => (
<FormItem className="flex items-center justify-between">
<FormLabel>Email Notifications</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)} />
<FormField control={form.control} name="theme" render={({ field }) => (
<FormItem>
<FormLabel>Theme</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger><SelectValue /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
<Button type="submit">Save settings</Button>
</form>
</Form>
);
}Field + InputGroup Composition
<Field label="Website URL" error={errors.url} required>
<InputGroup>
<InputGroupAddon position="inline-start">
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput placeholder="example.com" />
</InputGroup>
</Field>Multi-Field Form Layout
<div className="grid grid-cols-2 gap-4">
<Field label="First Name" required>
<Input placeholder="Jane" />
</Field>
<Field label="Last Name" required>
<Input placeholder="Doe" />
</Field>
</div>
<Field label="Email" required>
<InputGroup>
<InputGroupAddon position="inline-start">
<Mail className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput type="email" placeholder="jane@example.com" />
</InputGroup>
</Field>
<FieldRow label="Subscribe to newsletter">
<Checkbox />
</FieldRow>Zod Validation with React Hook Form
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Form, FormField, FormItem, FormLabel, FormControl, FormMessage,
} from '@constructive-io/ui/form';
import { Input } from '@constructive-io/ui/input';
import { Button } from '@constructive-io/ui/button';
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.coerce.number().min(18, 'Must be 18 or older'),
});
type FormData = z.infer<typeof schema>;
function ValidatedForm() {
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '', age: undefined },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(console.log)} className="space-y-4">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="age" render={({ field }) => (
<FormItem>
<FormLabel>Age</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<Button type="submit">Submit</Button>
</form>
</Form>
);
}Error Display Patterns
// Field-level error
<Field label="Email" error="This email is already taken">
<Input type="email" aria-invalid />
</Field>
// FormMessage auto-displays from react-hook-form state
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage /> {/* Renders error string from RHF field state */}
</FormItem>
)} />
// FormControl stacked with error
<FormControl label="Username" error="Username is taken">
<Input aria-invalid />
</FormControl>Error styling: aria-invalid on the input triggers red border via CSS. FormMessage / error prop renders red text below the field.
Best Practices
- Use
Fieldfor simple forms without validation libraries - Use
Form+FormFieldwhen you need validation (react-hook-form) FormControlfloating label requiresplaceholder=" "on the input- InputGroup coordinates focus/error states across all children via CSS
:has() - Combine Field with InputGroup for labeled inputs with addons
- Use
FieldRowfor boolean controls (checkbox, switch) that sit beside their label - Always set
defaultValuesinuseFormto avoid uncontrolled-to-controlled warnings - Use
zodResolverfor schema-based validation over inlinerules - Keep form state at the page/feature level, not in global stores
- Use
'use client'directive on any component that callsuseForm
Sheet Stacking Reference
Deep dive into the SheetStackProvider system for multi-level sheet navigation.
Setup
import { SheetStackProvider } from '@constructive-io/ui/sheet';
// Wrap your app/section to enable sheet stacking
<SheetStackProvider mode="cascade">
{children}
</SheetStackProvider>Stack Modes
cascade (default)
Each nested sheet indents by SHEET_INDENT (24px), creating a cascading stack effect. Previous sheets remain partially visible behind the new sheet.
<SheetStackProvider mode="cascade">
<Sheet>
<SheetTrigger asChild><Button>Open First</Button></SheetTrigger>
<SheetContent>
{/* First sheet, full width */}
<Sheet>
<SheetTrigger asChild><Button>Open Second</Button></SheetTrigger>
<SheetContent>
{/* Second sheet, indented 24px from first */}
</SheetContent>
</Sheet>
</SheetContent>
</Sheet>
</SheetStackProvider>collapse
Previous sheet is pushed/collapsed to reveal the new sheet. Only the topmost sheet is fully visible.
<SheetStackProvider mode="collapse">
{children}
</SheetStackProvider>Hooks
useSheetStack
Access stack metadata from any component inside the provider.
import { useSheetStack } from '@constructive-io/ui/sheet';
function SheetContent() {
const { stackCount, isInStack } = useSheetStack();
return (
<div>
<p>Sheets open: {stackCount}</p>
<p>Is stacked: {isInStack ? 'yes' : 'no'}</p>
</div>
);
}useSheet
Access the current sheet's state and actions.
import { useSheet } from '@constructive-io/ui/sheet';
function SheetBody() {
const { close, isOpen, side } = useSheet();
return (
<div>
<p>Side: {side}</p>
<Button onClick={close}>Close this sheet</Button>
</div>
);
}Nested Sheets Example
<Sheet>
<SheetTrigger asChild><Button>Open First</Button></SheetTrigger>
<SheetContent>
<SheetHeader><SheetTitle>List View</SheetTitle></SheetHeader>
<ul>
{items.map((item) => (
<li key={item.id}>
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost">{item.name}</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader><SheetTitle>{item.name}</SheetTitle></SheetHeader>
{/* Detail view -- stacks on top of list */}
<Sheet>
<SheetTrigger asChild><Button>Edit</Button></SheetTrigger>
<SheetContent>
<SheetHeader><SheetTitle>Edit {item.name}</SheetTitle></SheetHeader>
{/* Edit form -- third level */}
</SheetContent>
</Sheet>
</SheetContent>
</Sheet>
</li>
))}
</ul>
</SheetContent>
</Sheet>Global Escape Handling
- Escape key closes the topmost sheet in the stack
- Backdrop click closes the topmost sheet
- Each sheet manages its own animation independently
- Closing a parent sheet also closes all child sheets in the stack
Animation Details
- Uses
motion.divfrom motion/react withsprings.panelfor smooth transforms - Side-specific transforms:
right--translateX(100%)totranslateX(0)left--translateX(-100%)totranslateX(0)top--translateY(-100%)totranslateY(0)bottom--translateY(100%)totranslateY(0)- Cascade mode applies
translateX(-(stackIndex * SHEET_INDENT))to underlying sheets - Exit animations reverse the enter transform
- Backdrop opacity animates in sync with sheet position
Width Customization
// Fixed width
<SheetContent side="right" className="w-[400px]">
// Responsive width
<SheetContent side="right" className="w-full sm:w-[540px] lg:w-[720px]">
// Max width with fill
<SheetContent side="right" className="w-full max-w-2xl">Stacking with Different Sides
Sheets can stack even when using different sides. Each sheet animates from its own direction independently.
<Sheet>
<SheetTrigger asChild><Button>Open Right</Button></SheetTrigger>
<SheetContent side="right">
<Sheet>
<SheetTrigger asChild><Button>Open Bottom</Button></SheetTrigger>
<SheetContent side="bottom">
{/* Bottom sheet stacks on top of right sheet */}
</SheetContent>
</Sheet>
</SheetContent>
</Sheet>