Widget Development Guide
Complete guide to creating, translating, and testing custom widgets for SveltyCMS.
Last updated: 2/3/2026
Widget Development Guide
This guide covers everything you need to know about building 3-Pillar widgets, from your first component to advanced multilingual support and testing.
π Quick Start: Creating a Widget
1. Planning & Folder Structure
Create a new directory in src/widgets/custom/. The folder name must match your widget name exactly.
src/widgets/custom/my-widget/
βββ index.ts # Definition Pillar
βββ input.svelte # Input Pillar
βββ display.svelte # Display Pillar
βββ types.ts # Type Definitions
2. The Definition Pillar (index.ts)
This file defines your widgetβs capabilities, validation, and metadata.
import { createWidget } from '@widgets/widgetFactory';
import * as v from 'valibot';
export default createWidget({
Name: 'my-widget',
Icon: 'mdi:star',
Description: 'A custom widget example',
// Component Paths
inputComponentPath: '/src/widgets/custom/my-widget/input.svelte',
displayComponentPath: '/src/widgets/custom/my-widget/display.svelte',
// Validation Schema (Valibot)
validationSchema: (field) => {
const schema = v.string([v.minLength(1)]);
// Handle multilingual object structure if translated
return field.translated ? v.record(v.string(), schema) : schema;
},
defaults: {
translated: true
}
});
3. The Input Pillar (input.svelte)
Handles user interaction and data binding.
<script lang="ts">
import { app } from '@stores/store.svelte';
import { DEFAULT_CONTENT_LANGUAGE } from '@src/utils/constants';
let { field, value = $bindable() } = $props();
// Reactive Language Selection
const _language = $derived(field.translated ? app.contentLanguage : DEFAULT_CONTENT_LANGUAGE);
// Safe Value Access
const safeValue = $derived(field.translated && value ? (value[_language] ?? '') : (value ?? ''));
function update(newValue) {
if (field.translated) {
value = { ...(value || {}), [_language]: newValue };
} else {
value = newValue;
}
}
</script>
<input type="text" value={safeValue} oninput={(e) => update(e.currentTarget.value)} placeholder={field.label} />
4. The Display Pillar (display.svelte)
Read-only rendering for lists and previews.
<script lang="ts">
import { app } from '@stores/store.svelte';
let { field, value } = $props();
const displayValue = $derived(() => {
if (field.translated && value) {
return value[app.contentLanguage] || 'β';
}
return value || 'β';
});
</script>
<span>{displayValue}</span>
π Multilingual Support
SveltyCMS uses a dual-language system:
systemLanguage: For UI text (labels, buttons). Handled by Paraglide-JS.contentLanguage: For user content. Handled byapp.contentLanguage.
Best Practices
- Always check
field.translated: Donβt assume data is an object or a string. - Use
$derived: Language switching must be reactive. - Preserve Translations: When updating, merge with existing object properties:
value = { ...value, [lang]: new }. - Validation: Your schema must accept both
string(untranslated) andRecord<string, string>(translated).
π§ͺ Testing Your Widget
We use Vitest and Testing Library.
Unit Testing (input.test.ts)
import { render, fireEvent, screen } from '@testing-library/svelte';
import { app } from '@stores/store.svelte';
import Input from './input.svelte';
test('updates correct language', async () => {
app.contentLanguage = 'en';
let value = { en: '', de: 'Hallo' };
const { getByRole } = render(Input, {
field: { translated: true },
value
});
const input = getByRole('textbox');
await fireEvent.input(input, { target: { value: 'Hello' } });
expect(value.en).toBe('Hello');
expect(value.de).toBe('Hallo'); // Preserved
});
Validation Testing
import { parse } from 'valibot';
import Widget from './index';
test('validates multilingual structure', () => {
const schema = Widget.validationSchema({ translated: true });
expect(() => parse(schema, { en: 'Valid' })).not.toThrow();
expect(() => parse(schema, 'Invalid String')).toThrow();
});
π Security Checklist
- Sanitization: Encode HTML output in
display.svelteunless explicitly using a sanitizer. - Validation: Ensure strict schemas (e.g., regex for colors/phones).
- Input Limits: Set
maxLengthdefaults to prevent DB bloat. - Dependencies: Avoid heavyweight libraries if native browser features suffice.
π‘οΈ Strict Compliance Checklist (Marketplace Ready)
To ensure your widget is accepted into the SveltyCMS Marketplace, it must meet these strict criteria:
1. Accessibility (WCAG 2.2 AA / WCAG 3.0 Gold)
- Labels: EVERY input must have a visible
<label>with aforattribute matching the inputβsid.- β
<span class="label">Name</span> <input /> - β
<label for="name">Name</label> <input id="name" />
- β
- Error States: Invalid inputs must have:
aria-invalid="true"aria-describedby="field-error"pointing to the error message ID.
- Keyboard Navigation: All interactive elements (custom dropdowns, ratings, drag-handles) must be focusable (
tabindex="0") and operatable via Enter/Space/Arrows. - Dynamic Content: Use
aria-live="polite"for dynamic updates (loading states, characters remaining).
2. Performance & Architecture
- Lazy Loading: Import heavy libraries (Maps, Charts, Editors) dynamically inside
onMountoreventhandlers.const module = await import('heavy-lib');
- Debouncing: Search/API inputs must be debounced (min 300ms) to prevent server flooding.
- Iconography: Use
iconify-iconweb component. Do NOT bundle SVG libraries. - Styling: Use Tailwind CSS utility classes exclusively. Avoid
<style>blocks with custom CSS to ensure theme compatibility.
3. Flexibility
- No Hardcoded Secrets: Never hardcode API keys. Pass them via
publicEnvor widget configuration props. - Resilience: Gracefully handle missing configuration (e.g., βMap unavailable - API Key missingβ instead of crashing).
- Self-Contained: The widget folder should contain all necessary component logic. Shared logic should come from
@utilsor@stores.