LLM Reference
LLM Reference: Custom Components
Svelte 5 Runes
All Svelte components in Vextro and consuming apps must use Svelte 5 rune syntax. Do not use Svelte 3/4 patterns (export let, $:, <slot />).
Rune Reference
| Rune | Replaces | Purpose |
|---|---|---|
$state() | let x = value | Reactive mutable state |
$derived() | $: x = expr | Computed values from state |
$derived.by(() => ...) | $: { ... } | Computed values with multi-line logic |
$effect(() => ...) | $: { sideEffect() } | Side effects that run on state change |
$props() | export let | Component props |
Example: custom input component using all four runes
<script lang="ts">
import type { VextroCustomFieldProps } from "vextro";
// $props() replaces "export let" — props are typed via the interface
let {
name,
value,
onChange,
fieldDefinition,
disabled = false,
error,
}: VextroCustomFieldProps = $props();
// $state() — reactive mutable state (replaces "let x = value")
let localValue = $state(value ?? "");
let copyState = $state<"idle" | "copied">("idle");
// $derived() — computed from state (replaces "$: x = expr")
let charCount = $derived(String(localValue).length);
let isOverLimit = $derived(charCount > 100);
// $derived.by() — multi-line computed logic
let statusText = $derived.by(() => {
if (isOverLimit) return `${charCount}/100 — too long`;
return `${charCount}/100`;
});
// $effect() — side effect when state changes (replaces "$: { ... }")
$effect(() => {
if (localValue !== value) {
onChange?.(localValue);
}
});
function handleInput(e: Event) {
localValue = (e.target as HTMLInputElement).value;
}
</script>
<input value={localValue} oninput={handleInput} {disabled} />
<input type="hidden" {name} value={localValue} />
{#if error}
<p class="text-xs text-danger">{error}</p>
{/if}
<p class="text-xs text-fg-muted">{statusText}</p>
Event handling
Use callback props instead of custom events or on: directives:
<!-- Correct: callback prop -->
<script lang="ts">
interface Props {
onchange?: (value: string) => void;
}
let { onchange }: Props = $props();
</script>
<input onchange={(e) => onchange?.(e.currentTarget.value)} />
<!-- Avoid: legacy on: directive -->
<input on:change />
Snippets vs Slots
Svelte 5 uses snippets instead of <slot />. Import Snippet from 'svelte' and render with {@render}.
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
header?: Snippet;
children: Snippet;
footer?: Snippet<[{ closeModal: () => void }]>;
}
let { header, children, footer }: Props = $props();
function closeModal() { /* ... */ }
</script>
<div class="card">
{#if header}
<div class="card-header">{@render header()}</div>
{/if}
<div class="card-body">{@render children()}</div>
{#if footer}
<div class="card-footer">{@render footer({ closeModal })}</div>
{/if}
</div>
Snippets can also be defined inline in the parent and passed as props:
<MyCard>
{#snippet header()}
<h2>Title</h2>
{/snippet}
Default children content here.
</MyCard>
SvelteFieldRenderer Field Type Mapping
SvelteFieldRenderer is the central client-side dispatcher that maps a field’s fieldType string to the correct input component. When building custom fields or plugins, use this table to understand the built-in mapping and where custom types plug in.
fieldType | Component | Notes |
|---|---|---|
text, email, url, password | VextroTextInput | All four map to the same text input |
textarea | VextroTextareaInput | Multi-line text |
number | VextroNumberInput | Numeric input |
checkbox | VextroCheckboxInput | Inline label rendering (no wrapper) |
date | VextroDateInput | Date-only picker |
datetime | VextroDateTimePicker | Date + time picker; accepts locale, hourCycle config |
slug | VextroSlugInput | URL-safe slug |
json | VextroJsonInput | Raw JSON editor |
select | VextroSelectInput | Single-select dropdown |
select + config.multiple: true | VextroMultiSelectInput | Multi-select (same fieldType, different config) |
multiselect | VextroMultiSelectInput | Explicit multi-select alias |
radio | VextroRadioInput | Radio group; accepts config.layout: "horizontal" | "vertical" |
code | VextroCodeInput | Code editor with syntax highlighting |
richText | VextroWysiwygTipTap | TipTap rich text; config.output: "json" | "html" |
color | VextroColorInput | Color picker; accepts config.formats |
point | VextroPointInput | Lat/lng picker; accepts config.showMap, config.defaultCenter |
seo | VextroSeoEditor | SEO metadata block |
group | VextroGroupInput | Nested field group |
array | VextroArrayInput | Repeating row array |
blocks | VextroBlockInput | Block array editor |
relationship | VextroRelationshipInput | Relationship picker; accepts config.multiple, config.displayAs |
upload, image + config.relationTo, file + config.relationTo | VextroUploadIsland | File/image upload |
join | (inline) | Read-only reverse relationship; not editable |
section | (inline) | Visual divider/heading only; no data |
virtual | (inline) | Computed read-only display |
row | SvelteRowContainer | Layout container: horizontal row of fields |
collapsible | SvelteCollapsibleContainer | Layout container: collapsible section |
tabs | SvelteTabsContainer | Layout container: tabbed sections |
| (registered plugin type) | VextroCustomFieldWrapper | Any type registered via setPluginFieldRenderers |
| (unknown) | (fallback) | Shows “Unknown field type” warning + hidden input |
Plugin renderer resolution order
- Check the
pluginRenderersprop passed from the Astro layer (Svelte-to-Svelte rendering). - Fall back to
getPluginFieldRenderers()from the client store (Astro island rendering). - If no renderer found, render the fallback warning.
Bits UI Compound Component Pattern
Vextro uses Bits UI for accessible interactive primitives. All Bits UI components follow a compound pattern where Root wraps child sub-components.
Import pattern
<script lang="ts">
import { Dialog, Select, DropdownMenu, Popover, Checkbox } from "bits-ui";
</script>
Dialog example
<Dialog.Root>
<Dialog.Trigger class="btn">Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black/50" />
<Dialog.Content class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-lg p-6">
<Dialog.Title>Confirm action</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<Dialog.Close class="btn">Cancel</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
Available primitives
| Primitive | Sub-components |
|---|---|
Dialog | Root, Trigger, Portal, Overlay, Content, Title, Description, Close |
DropdownMenu | Root, Trigger, Portal, Content, Item, Sub, SubTrigger, SubContent, Separator, Label |
Select | Root, Trigger, Value, Portal, Content, Item, ItemText, Separator, Label |
Popover | Root, Trigger, Portal, Content, Close |
Checkbox | Root, Indicator |
RadioGroup | Root, Item, Indicator |
Switch | Root, Thumb |
Tabs | Root, List, Trigger, Content |
Accordion | Root, Item, Header, Trigger, Content |
Combobox | Root, Input, Portal, Content, Item, ItemText, Empty |
Key rules when using Bits UI
- Always include
Dialog.TitleandDialog.DescriptioninsideDialog.Contentfor accessibility. - Use
Dialog.Portalto render overlays outside the normal DOM flow. - Provide
aria-labelon any trigger that lacks visible text. - Bits UI components are accessible by default — do not override role/aria attributes unless required.
VextroPlugin
The core plugin type. Bundles custom field renderers, toolbar actions, widgets, and hooks.
type VextroPlugin = {
name: string;
version: string;
hooks?: VextroPluginHooks;
fieldPlugins?: VextroFieldPlugin[];
toolbarActions?: VextroToolbarAction[];
dashboardWidgets?: VextroDashboardWidget[];
sidebarWidgets?: VextroSidebarWidget[];
};
VextroPluginHooks
type VextroPluginHooks = {
onDocumentCreate?: (context: VextroHookContext) => void | Promise<void>;
onDocumentUpdate?: (context: VextroHookContext) => void | Promise<void>;
onDocumentDelete?: (context: VextroHookContext) => void | Promise<void>;
onBeforeSave?: (context: VextroBeforeSaveContext) => void | Promise<void>;
onAfterSave?: (context: VextroAfterSaveContext) => void | Promise<void>;
};
type VextroHookContext = {
collectionSlug: string;
documentId?: string;
data: Record<string, unknown>;
user?: { id: string; email?: string; [key: string]: unknown };
};
type VextroBeforeSaveContext = VextroHookContext & { isNew: boolean };
type VextroAfterSaveContext = VextroHookContext & { isNew: boolean };
VextroFieldPlugin
Register custom field types with custom rendering, validation, and serialization.
type VextroFieldPlugin = {
fieldType: string;
label?: string;
component: unknown; // Svelte component
validator?: (value: unknown, config?: Record<string, unknown>) => string | null;
serializer?: (value: unknown) => unknown;
deserializer?: (value: unknown) => unknown;
};
| Field | Description |
|---|---|
fieldType | Unique identifier (e.g., "starRating", "mapPicker") |
label | Human-readable name for field type pickers |
component | Svelte component accepting VextroCustomFieldProps |
validator | Client-side validation. Return null if valid, error string if not |
serializer | Transform editor value to database format |
deserializer | Transform database value to editor format |
VextroCustomFieldProps
Props that custom field Svelte components must accept:
type VextroCustomFieldProps = {
name: string;
value: unknown;
onChange?: (value: unknown) => void;
fieldDefinition: VextroCustomFieldDefinition;
disabled?: boolean;
error?: string;
};
type VextroCustomFieldDefinition = {
name: string;
label: string;
fieldType: string;
config?: Record<string, unknown>;
description?: string;
};
Important: onChange availability
- Top-level fields (rendered by Astro SSR via VextroFieldInput.astro):
onChangeis NOT provided because functions cannot cross the SSR-to-client serialization boundary. The component must include its own<input type="hidden" name={name} value={...} />for form POST serialization. - Nested fields (inside Svelte parent like group/array via VextroCustomFieldWrapper):
onChangeIS provided and handles value propagation automatically.
Example Svelte Component
<script lang="ts">
import type { VextroCustomFieldProps } from "vextro";
let {
name,
value,
onChange,
fieldDefinition,
disabled = false,
error,
}: VextroCustomFieldProps = $props();
let localValue = $state(value ?? '');
function handleInput(e: Event) {
localValue = (e.target as HTMLInputElement).value;
onChange?.(localValue);
}
</script>
<input value={localValue} oninput={handleInput} disabled={disabled} />
<input type="hidden" {name} value={localValue} />
VextroToolbarAction
type VextroToolbarAction = {
label: string;
icon?: unknown;
handler: () => void | Promise<void>;
position: "left" | "right";
};
VextroDashboardWidget
type VextroDashboardWidget = {
label: string;
component: unknown;
width: "full" | "half" | "third";
priority: number; // lower = appears first
};
VextroSidebarWidget
type VextroSidebarWidget = {
label: string;
component: unknown;
position: "top" | "bottom";
collectionFilter?: string[]; // only show for these collection slugs
};
definePlugin
Helper to type-check a plugin definition (pass-through):
import { definePlugin } from "vextro";
const myPlugin = definePlugin({
name: "my-plugin",
version: "1.0.0",
fieldPlugins: [{ fieldType: "starRating", component: StarRating }],
});
Registering Custom Field Renderers
Svelte islands rendered via Astro’s client:* directives cannot receive non-serializable props. Use setPluginFieldRenderers to register custom renderers at app startup.
setPluginFieldRenderers
function setPluginFieldRenderers(renderers: Map<string, VextroFieldPlugin>): void;
Call once during app initialization in a headless Svelte component with client:load:
<!-- src/components/PluginInit.svelte -->
<script lang="ts">
import { setPluginFieldRenderers } from "vextro";
import type { VextroFieldPlugin } from "vextro";
import StarRatingInput from "./StarRatingInput.svelte";
const renderers = new Map<string, VextroFieldPlugin>();
renderers.set("starRating", {
fieldType: "starRating",
component: StarRatingInput,
});
setPluginFieldRenderers(renderers);
</script>
<!-- AdminLayout.astro -->
<PluginInit client:load />
<slot />
Custom Relationship Renderers
Custom renderers for how relationship items display in the editor. Renderers are keyed by collection slug (e.g., "employees") or by "{collectionSlug}:{fieldName}" for per-field overrides.
import { setRelationshipRenderers, getRelationshipRenderer } from "vextro";
type RelationshipRenderer =
| ((doc: Record<string, unknown>) => string)
| ((doc: Record<string, unknown>) => { component: unknown; props: Record<string, unknown> });
setRelationshipRenderers
function setRelationshipRenderers(renderers: Map<string, RelationshipRenderer>): void;
Register renderers in a headless Svelte component rendered with client:load:
<script lang="ts">
import { setRelationshipRenderers } from "vextro";
const renderers = new Map();
renderers.set("employees", (doc) => `${doc.firstName} ${doc.lastName} — ${doc.email}`);
renderers.set("employees:manager", (doc) => ({
component: EmployeeCard,
props: { name: doc.firstName, avatar: doc.avatar },
}));
setRelationshipRenderers(renderers);
</script>
getRelationshipRenderer / getRelationshipRenderers
function getRelationshipRenderer(key: string): RelationshipRenderer | undefined;
function getRelationshipRenderers(): Map<string, RelationshipRenderer>;
At render time, VextroRelationshipInput checks this store when a relationship field’s displayAs is set to "__custom" (the serialized marker for function-based display).
VextroInputOverrides
type VextroInputComponent = (props: Record<string, unknown>) => unknown;
type VextroInputOverrides = {
components?: Record<string, VextroInputComponent>;
context?: Record<string, unknown>;
};
Set in VextroConfig to globally override built-in field input components:
const config: VextroConfig = {
brandName: "My App",
inputOverrides: {
components: {
richText: MyCustomRichTextEditor,
},
context: { apiKey: "..." },
},
};
f.custom() and f.ui()
f.custom(validator, adminType, config?)
Register a field with a raw Convex validator and a custom admin type:
address: f.custom(
v.object({ street: v.string(), city: v.string(), zip: v.string() }),
"address",
{ defaultCountry: "US" }
)
The adminType must match a registered fieldType from a plugin or input override.
f.ui()
Create a UI-only slot (no data storage):
preview: f.ui({
component: "DocumentPreview",
props: { showMeta: true },
watchFields: ["title", "body"],
})
| Option | Type | Description |
|---|---|---|
component | string | Registered component name |
props | Record<string, unknown> | Props passed to component |
watchFields | string[] | Re-render when these fields change |
label | string | Display label |
condition | FieldCondition | Conditional display |