Molly Ollys UI Kit
Current Version: v1.5.1
Header
Sticky site header with the Molly Ollys butterfly logo and wordmark. Pass appName to set the app label beside the brand name. Provide links to render inline nav links in the header bar. Optionally provide userName, userMessage, accountUrl, and signOutHref to render a user area on the right. The bottom bar doubles as a page loader — call setPageLoaderState() in your root layout and use pageLoader.start() / pageLoader.stop() from any component to animate it.
<Header appName="Portal" />
<!-- With inline nav links -->
<Header
appName="500 Club"
links={[
{ href: "#how-it-works", label: "How it works" },
{ href: "#faq", label: "FAQ" },
]}
/>
<!-- Page loader — call setPageLoaderState() once in your root layout alongside Header.
The Header reads it from context automatically. -->
// root layout (+layout.svelte)
setPageLoaderState();
// any component
const pageLoader = getPageLoaderState();
pageLoader.start(); // animate the bar
pageLoader.stop(); // return to static lineSectionTitle
Small uppercase label for grouping content within a panel or card. Renders as an <h3> with secondary text colour.
Personal details
Address
<SectionTitle>Personal details</SectionTitle>
Horizontal Rule
Simple horizontal rule with vertical margins. Renders as an <hr> element styled with a light grey colour. Optional children are displayed centred between two rule lines.
Section above the rule
Section below the rule
<HorizontalRule />
Sign in with email
or
Continue with SSO
<HorizontalRule>or</HorizontalRule>
LayoutItem
Grid cell wrapper for non-labeled content inside a LayoutSection. Renders a plain <div> with no label — use it for buttons, headings, or any content that needs grid placement without a form label. Supports the same span and fullWidth props as FormItem. Use title to render a SectionTitle above the content, and actions to anchor a snippet of buttons to the top-right corner. Use card to wrap content in a Card component, or detailCard to apply a --surface-alt background with border and padding.
<LayoutSection cols={2}>
<FormItem label="First name">...</FormItem>
<FormItem label="Last name">...</FormItem>
<!-- LayoutItem for non-labeled content that needs grid placement -->
<LayoutItem fullWidth>
<Button type="submit">Save</Button>
</LayoutItem>
</LayoutSection> Personal details
- Name
- Joe Herbert
- joe@example.com
- Phone
- 07700 900123
Address
- Line 1
- 12 Baker Street
- City
- London
- Postcode
- W1U 3BH
<!-- With title only -->
<LayoutItem title="Personal details">
<dl>...</dl>
</LayoutItem>
<!-- card: wraps content in a Card component -->
<LayoutItem title="Address" card>
{#snippet actions()}
<Button size="sm" variant="secondary">Edit</Button>
{/snippet}
<dl>...</dl>
</LayoutItem>
<!-- detailCard: --surface-alt background with border/padding; actions anchored top-right -->
<LayoutItem title="Notes" card detailCard>
<p>Some content</p>
</LayoutItem>LayoutSection
Grid container for laying out content. Use cols for a fixed column count or minWidth for responsive auto-fit wrapping. gap controls spacing (default 8px). fullWidth makes it span all columns when nested inside another grid. Use detailCard to apply a --surface-alt background with border and padding, card to wrap the section in a Card component, or dashed for a dashed border only.
Fixed columns — cols=3
Responsive auto-fit — minWidth=160
Nested — fullWidth inside cols=2
detailCard
card
dashed
<!-- Fixed columns -->
<LayoutSection cols={3}>
<FormItem label="Title">...</FormItem>
<FormItem label="First name" span={2}>...</FormItem>
<FormItem label="Email" span={3}>...</FormItem>
</LayoutSection>
<!-- Responsive: wraps when items would be narrower than 160px -->
<LayoutSection minWidth={160}>
<FormItem label="City">...</FormItem>
<FormItem label="Postcode">...</FormItem>
</LayoutSection>
<!-- Nested: fullWidth spans parent grid, then defines its own -->
<LayoutSection cols={2}>
<FormItem label="City">...</FormItem>
<FormItem label="Postcode">...</FormItem>
<LayoutSection fullWidth cols={3}>
<FormItem label="Month">...</FormItem>
<FormItem label="Day">...</FormItem>
<FormItem label="Year">...</FormItem>
</LayoutSection>
</LayoutSection>
<!-- detailCard: --surface-alt background with border and padding -->
<LayoutSection cols={2} detailCard>
<FormItem label="City">...</FormItem>
<FormItem label="Postcode">...</FormItem>
</LayoutSection>
<!-- card: wraps in a Card component -->
<LayoutSection cols={2} card>
<FormItem label="City">...</FormItem>
<FormItem label="Postcode">...</FormItem>
</LayoutSection>
<!-- dashed: dashed border only -->
<LayoutSection cols={2} dashed>
<FormItem label="City">...</FormItem>
<FormItem label="Postcode">...</FormItem>
</LayoutSection>Badge
Inline status or category indicator. Seven colour variants plus a compact mode that renders uppercase with tighter padding.
Variants
Compact
<Badge variant="success">Active</Badge>
BadgeList
Renders a string[] as a row of Badge components. Pass any array of strings and it handles the layout. Supports onchipclick callback function and gap property, defaulting to '6px'.
<BadgeList tags={["Svelte", "TypeScript", "bits-ui"]} />Card
General-purpose surface container. Four variant options control shadow and border, and three padding sizes control inner spacing.
Variants
default
shadow-sm
elevated
shadow-md
prominent
shadow-strong
outlined
border, no shadow
Padding
padding sm
14px
padding md
20px (default)
padding lg
28px
<Card variant="elevated" padding="lg">Content</Card>
CardBlob
Stat/dashboard card with an animated pink blob that reveals on hover. Uses a frosted-glass inner layer over the bouncing blob. Hover the card to see the effect.
Supporters
Donations
<CardBlob> <h3>Supporters</h3> <span class="number">1,284</span> </CardBlob>
Table
Data-driven table with overflow scroll, sorting, and empty-state handling. Pass headers (with optional sort) and records as plain arrays. Provide onrowclick and onrowcontextmenu callbacks for row interactions. Sorting is managed internally. Override any cell by passing a snippet named cell_{key}. Pass a footer record to render a summary row in <tfoot> — it uses the same keys as headers and supports the same cell snippets.
Basic
| Name | Role | Status |
|---|---|---|
| Joe Herbert | Admin | Active |
| Bob Smith | Editor | Inactive |
| Carol White | Viewer | Active |
| Dan Brown | Editor | Pending |
<Table
headers={[{ label: "Name", key: "name", sort: true }, ...]}
records={rows}
onrowclick={(row) => console.log(row)}
/> With cell snippets
| Tags | |||
|---|---|---|---|
| Bob Smith | Editor | Inactive | |
| Carol White | Viewer | Active | |
| Dan Brown | Editor | Pending | |
| Joe Herbert | Admin | Active |
<Table {headers} {records}>
{#snippet cell_status(row)}
<Badge variant={statusVariant[row.status]}>{row.status}</Badge>
{/snippet}
{#snippet cell_tags(row)}
<BadgeList tags={row.tags} />
{/snippet}
</Table> Multi-select rows (shift+click)
| Bob Smith | Editor | Inactive |
| Carol White | Viewer | Active |
| Dan Brown | Editor | Pending |
| Joe Herbert | Admin | Active |
<Table
clickable
multiselect
{headers}
{records}
onrowclick={(row) => console.log(row)}
onrowcontextmenu={(rows) => console.log(rows)}
onselectionchange={(rows) => (selected = rows)}
bind:clearSelection
/> With footer row
| Item | Qty | Amount |
|---|---|---|
| Ticket — Adult | 3 | £75.00 |
| Ticket — Child | 2 | £30.00 |
| Donation | 1 | £50.00 |
| Total | 6 | £155.00 |
<Table
{headers}
{records}
footer={{ item: "Total", qty: 6, amount: "£155.00" }}
/>AccordionTable/AccordionTableGroup
Collapsible row for grouping a Table under a labelled header. Use AccordionTable on its own for a single group, or pass a tables array to AccordionTableGroup when stacking multiple — it provides the shared shadow and clips borders correctly. Provide meta strings for count/total metadata beside the title.
Single
<AccordionTable title="FY 2025/26" {headers} {records}>
{#snippet meta()}<span>4 donations</span><span>£2,400</span>{/snippet}
</AccordionTable> Grouped
<AccordionTableGroup
tables={[
{ title: "FY 2025/26", meta: ["4 donations", "£2,400"], headers, records },
{ title: "FY 2024/25", meta: ["2 donations", "£900"], headers, records },
]}
/>Accordion
Collapsible section with an animated chevron and slide transition. Bind open to control or read state externally. Pass a meta snippet for supplementary text beside the title. Use fullWidth inside a grid to span all columns. Add large for a bigger card-style variant with a bolder header.
Default
<Accordion title="Personal details">
<dl>...</dl>
</Accordion>
<!-- With meta -->
<Accordion title="Pledge">
{#snippet meta()}(optional){/snippet}
<dl>...</dl>
</Accordion> Large
<Accordion large title="Users (3)">
...content...
</Accordion>
<!-- Controlled -->
<Accordion large title="Settings" bind:open={isOpen}>
...content...
</Accordion>Form
Wrapper around the native <form> that surfaces SvelteKit action results. General errors appear as a banner below the form via result.message; per-field errors appear inline via result.fields and the companion FieldError component. Pass the SvelteKit enhance function via the enhance prop.
<Form method="POST" action="?/add" result={form} successMessage="Saved." enhance={(el) => enhance(el)}>
<Input name="name" placeholder="Name" />
<FieldError field="name" />
<Input name="email" type="email" placeholder="Email" />
<FieldError field="email" />
<Button type="submit" size="sm">Submit</Button>
</Form>FormItem
Labelled field wrapper. Renders a <label> that implicitly associates with any child input. Pass label for the label text and required to append a required asterisk. Use span to control how many columns it occupies inside a LayoutSection grid, or fullWidth to stretch across all columns. For non-labeled content that still needs grid placement, use LayoutItem instead.
<LayoutSection cols={2}>
<FormItem label="First name">...</FormItem>
<FormItem label="Last name">...</FormItem>
<!-- span={2} stretches across a specific number of columns -->
<FormItem label="Email address" required span={2}>...</FormItem>
<!-- fullWidth always spans all columns regardless of count -->
<FormItem label="Notes" fullWidth>...</FormItem>
</LayoutSection>FieldError
Renders a per-field validation message sourced from the nearest Form context. Pass field matching the input's name attribute; the component renders nothing when there is no error for that field. Must be used inside a Form component.
<Form result={form}>
<Input name="username" placeholder="Username" />
<FieldError field="username" />
</Form>Fieldset
Labelled form section with a pink legend. Pass title for the legend text, an optional actions snippet for buttons anchored to the top-right, and elements within the Fieldset for the body content.
Without actions
With actions
<Fieldset title="Personal details">
<Input name="first-name" placeholder="First name" />
</Fieldset>
<!-- With actions -->
<Fieldset title="Address">
{#snippet actions()}
<Button size="sm" variant="secondary">Edit</Button>
{/snippet}
...
</Fieldset>Input
Base text input. Accepts all standard HTML input attributes. Bind to value for two-way data binding. Supports placeholder, disabled, type, and any other native input prop.
<Input name="email" type="email" placeholder="you@example.com" bind:value />
Textarea
Multi-line text input sharing the same styles as Input. Accepts all standard textarea attributes. Use resize to control resize behaviour ("none", "both", "horizontal", "vertical"). Bind to value for two-way data binding.
<Textarea name="notes" rows=4 placeholder="Notes…" bind:value />
UnitInput
A text input with optional before and after adornments for units or labels. Accepts all standard input attributes. decimals controls how many decimal places to round to on blur. Bind to value for two-way data binding. Note that the value is always a string for flexibility with formatting and form serialisation; use the before/after props for unit display rather than including them in the value.
<UnitInput name="weight" before="kg" bind:value /> <UnitInput name="percentage" after="%" bind:value />
MoneyInput
GBP currency input with a £ prefix. Auto-formats to two decimal places on blur. Binds to a string value for use with form serialisation.
<MoneyInput name="price" bind:value />
FileInput
File picker with a Button trigger. Shows the selected filename (with extension) and size after selection. For multiple files, shows a count and total size. Supports accept for type filtering and multiple for multi-file selection.
Single file
Multiple files
Disabled
<FileInput label="Attachment" onchange={f => (files = f)} />
<!-- Multiple + type filter -->
<FileInput label="Documents" multiple accept=".pdf,.docx" buttonText="Choose Files" onchange={handler} />Checkbox
Accessible checkbox built on bits-ui. Supports two-way bind:checked, an optional description line beneath the label, and an onchange callback.
checked = false
<Checkbox name="accept" label="I agree" bind:checked />
Toggle
On/Off toggle switch. The sliding knob shows a tick or a cross and transitions between the pink palette states. Supports two-way bind:checked and an onchange callback.
checked = false
<Toggle name="feature" label="Enable feature" bind:checked />
ToggleGroup
Segmented button group built on bits-ui. Supports type="single" (at most one active) and type="multiple" (any number active). Set allowEmpty=true to allow deselecting the last active item. Controlled via value + onValueChange.
Single — allowEmpty (default)
value = week
Multiple — allowEmpty=false
value = [mon, wed]
<ToggleGroup type="single" {options} {value} allowEmpty={false} {onValueChange} />Select
Dropdown select built on bits-ui. Supports two-way bind:value and an onchange callback. Including an option with value: "Other" reveals a free-text input for custom values. clearable allows the user to reset the select.
<Select name="type" options={opts} bind:value clearable />SelectSearch
Fuzzy-search combobox for single selection. Type to filter options using uFuzzy. Clears cleanly when the input doesn't match any option. Useful for long option lists.
<SelectSearch name="country" options={opts} bind:value />MultiSelect
Multi-select trigger initially designed for filter bars. Shows a count badge when items are selected. Controlled via selected + onchange — ideal for syncing with URL search params or external filter state.
<MultiSelect label="Status" options={opts} {selected} {onchange} />MultiSelectSearch
Fuzzy-search combobox with multi-select. Type to filter options, click items to toggle selection — the dropdown stays open between picks. Shows a count in the placeholder and a ✕ clear button when items are selected. Pass showChips to render selected values as a BadgeList below the input, with an optional onChipClick callback.
Without chips
With chips + onChipClick
<MultiSelectSearch options={opts} {selected} {onchange} showChips {onChipClick} />DatePicker
Calendar date picker (GB locale) built on bits-ui. Binds to a YYYY-MM-DD string. Includes month/year dropdowns for fast navigation. Pairs with a hidden <input> for native form submission.
<DatePicker name="date" bind:value />
DateRangePicker
Range-only date picker for selecting a start and end date. Binds independently to startValue and endValue strings in YYYY-MM-DD format. Highlights the selected range in the calendar. Use DateSingleMultiPicker if you need a single/range toggle.
<DateRangePicker startName="from" endName="to" bind:startValue bind:endValue />
DateSingleMultiPicker
Composite picker that combines a ToggleGroup with DatePicker and DateRangePicker. The toggle switches between single-day and multi-day mode. In single-day mode only startValue is set; endValue is cleared. Bind multiDay to control or read the current mode.
mode = single
<DateSingleMultiPicker startName="from" endName="to" bind:startValue bind:endValue bind:multiDay />
Link
Styled anchor element. Renders pink with no underline by default; underline appears on hover. Supports all standard anchor props: href, target, rel, download, and class.
<Link href="/supporters/123">View supporter</Link> <!-- External --> <Link href="https://example.com" target="_blank" rel="noopener noreferrer">External</Link>
ConfirmDialog
Modal overlay dialog for confirmations. Requires a title, a message, and an onclose handler called when the backdrop is clicked. Action buttons go in the default slot.
<ConfirmDialog title="Delete?" message="This cannot be undone." onclose={close} open={showDialog}>
<Button variant="danger" onclick={confirm}>Delete</Button>
<Button variant="ghost" onclick={close}>Cancel</Button>
</ConfirmDialog>Loader
Loading indicators with eight visual variants controlled by the variant prop (default spinner). The spinner variant uses the --border and --pink-dark CSS variables. All variants accept a size prop (px) that sets the width — square variants (pulse, layers, image) scale height automatically via aspect-ratio. Accepts all standard <span> attributes — pass aria-label and aria-live for accessible indicators.
backlit (dark bg only) <Loader />
<!-- Named variant -->
<Loader variant="pulse" />
<Loader variant="backlit" />
<Loader variant="layers" />
<Loader variant="text" />
<Loader variant="graph" />
<Loader variant="line" />
<Loader variant="image" />
<!-- Custom size — sets width on all variants -->
<Loader size={32} />
<Loader variant="pulse" size={64} />
<!-- image variant: custom background colour (default: --surface-alt) -->
<Loader variant="image" bg="var(--pink-light)" />
<!-- Accessible saving indicator -->
<Loader aria-label="Saving…" aria-live="polite" />Toast
Non-blocking notification system. Call setToastState() once in your root layout and mount <Toast />. Then call getToastState() anywhere to fire toasts. Four severity variants with configurable auto-dismiss duration.
// root layout
setToastState();
<Toast />
// any component
const toast = getToastState();
toast.success("Saved!");Modal
Generic centered overlay dialog. Requires a title and an onclose handler. Content goes in the default slot. Optional width prop (default 560).
<Modal open={showModal} title="My Modal" onclose={() => (showModal = false)}>
<p>Modal content here.</p>
</Modal>SidePanel
Generic slide-in panel. Specify side ("left" or "right", default "right") and width in px (default 480). Clicking the overlay closes the panel. Pass an actions snippet to render buttons in the header beside the close button. Pass a footer snippet to render a bordered footer bar below the body, styled to match the header. Used internally by DocumentViewerPanel and HistoryPanel.
<SidePanel bind:open width={400} side="right" title="My panel">
...content...
</SidePanel>
<!-- With header actions -->
<SidePanel bind:open title="Edit record">
{#snippet actions()}
<Button size="sm" variant="ghost">Export</Button>
{/snippet}
...content...
</SidePanel>
<!-- With footer -->
<SidePanel bind:open title="Edit record">
...content...
{#snippet footer()}
<Button size="sm" type="submit">Save</Button>
<Button size="sm" variant="ghost" onclick={() => (open = false)}>Cancel</Button>
{/snippet}
</SidePanel>DocumentViewerPanel
Slide-in panel for previewing documents. Handles images, PDFs, video, audio, plain text, and CSV natively. Unsupported types fall back to a download prompt. Controlled via bind:open. Close by clicking the overlay or the ✕ button.
<DocumentViewerPanel bind:open src={url} filename="photo.jpg" mimeType="image/jpeg" downloadUrl={url} />
<DocumentViewerPanel bind:open src="/dummy-pdf.pdf" filename="dummy-pdf.pdf" mimeType="application/pdf" downloadUrl="/dummy-pdf.pdf" />HistoryPanel
Slide-in audit trail panel. Accepts either snapshot-based history (full JSON snapshots diffed against the previous version) or structured audit entries with explicit change lists. Each version row is collapsible. Controlled via bind:open.
<HistoryPanel bind:open {history} fieldLabels={{ first_name: "First name" }} />AddressAutofill
UK address lookup powered by Google Places. Calls /api/places/autocomplete as the user types (300 ms debounce, min 2 chars) then fetches full address details on selection. Fires onselect with a structured AutofilledAddress object containing line1, line2, city, county, postcode, country. Optional label prop overrides the field label (defaults to "Find address").
<AddressAutofill onselect={addr => console.log(addr)} />EntitySearch
Combobox for searching entities via an API endpoint. Fetches results from endpoint?q=… as the user types. Supports single-select (bind:value → UUID string) and multi-select (multiple + bind:selected → UUID array) modes. Use currentLabel to pre-populate the display label for an existing value on mount. Callbacks: onselect(uuid, option) for single mode, onmultiselect(uuids) for multi mode. Optional label, placeholder, and required props.
<!-- Single-select -->
<EntitySearch bind:value endpoint="/api/supporters" label="Supporter" currentLabel="Jo Smith" onselect={handleSelect} />
<!-- Multi-select -->
<EntitySearch multiple bind:selected endpoint="/api/supporters" label="Supporters" onmultiselect={handleMultiSelect} />ThemeSync
Non-visual utility component that syncs a theme preference ("light", "dark", or "system") to localStorage and the root element class. Mount it once in your root layout and bind your theme state to the theme prop. The dark class on <html> activates dark-mode CSS variable overrides from theme.css.
active = system
<!-- root +layout.svelte -->
<script>
import { ThemeSync } from "@molly-ollys/ui-kit";
let theme = $state<"light" | "dark" | "system">("system");
</script>
<ThemeSync {theme} />
<slot />SortState
Reactive class for managing external (e.g. server-side) sort state. Holds field and dir as Svelte 5 $state properties. Call toggle(field) to cycle direction or switch fields; read indicator(field) for a sort arrow string. Pass the pre-sorted records to Table and set sort=false on headers to disable the table's built-in sorting.
| Name | Role | Status |
|---|---|---|
| Bob Smith | Editor | Inactive |
| Carol White | Viewer | Active |
| Dan Brown | Editor | Pending |
| Joe Herbert | Admin | Active |
sort = name asc
import { SortState } from "@molly-ollys/ui-kit";
const sort = new SortState<"name" | "date">("name", "asc");
// Read sort state to pass to your API
$effect(() => {
fetchData({ sortBy: sort.field, sortDir: sort.dir });
});
// Render a sortable column header
<button onclick={() => sort.toggle("name")}>
Name {sort.indicator("name")}
</button>
// Pass pre-sorted rows — disable Table's built-in sort
<Table headers={[{ label: "Name", key: "name" }]} {records} />