22 KiB
UI Components
Source: src/lib/client/ui/
Import alias: $ui/* (maps to src/lib/client/ui/)
Lint rule: scripts/lint/ui.ts (deno task lint:ui)
Profilarr ships a small in-house component library that wraps the raw HTML
primitives the app needs: buttons, inputs, selects, textareas, dialogs, and
tables. Every page in src/routes/ is expected to consume these wrappers
instead of raw elements. Compliance is enforced by a custom Deno lint script
(no-raw-ui) that parses every .svelte file with the Svelte 5 compiler and
flags banned tags.
Table of Contents
The no-raw-ui Lint Rule
scripts/lint/ui.ts is a script that enforces the "wrappers
only" convention. It walks the Svelte 5 AST produced by
npm:svelte/compiler, reports each banned tag with a suggested replacement,
and exits non-zero on any violation.
deno task lint:ui # run the no-raw-ui rule alone
Exemptions
<input type="hidden"> is always allowed. Roughly 140 hidden inputs
exist in the codebase as SvelteKit form-action plumbing; they are not visible
UI and wrapping them would add noise. The exemption only applies when type
is a static string literal equal to "hidden". Dynamic type={foo}
expressions are flagged.
Escape-hatch comment. For the rare case where a wrapper is genuinely wrong (or doesn't exist yet), an HTML comment placed immediately before the element disables the rule for that line:
<!-- lint-disable-next-line no-raw-ui -- svelte-dnd-action needs a real <button> host -->
<button use:dragHandle>...</button>
The directive format is strict:
<!-- lint-disable-next-line no-raw-ui -- <non-empty reason> -->
A malformed directive (missing --, empty reason, wrong rule name) is
itself reported as a violation, so you cannot accidentally "pass" the linter
by writing a broken directive.
Component Catalog
Components are organized by functional category. Each major component (the ones that directly replace banned HTML, plus the most heavily used wrappers) is documented with three representative call sites. Minor components get a one-line description.
Inputs
FormInput
$ui/form/FormInput.svelte wraps <input> (and, with textarea={true},
<textarea>). It owns the label, description text, password visibility
toggle, monospace mode, and the size scale (xs / sm / md / lg).
A typical text field:
<!-- src/routes/settings/general/+page.svelte:616 -->
<FormInput
label="API Read Access Token"
name="tmdb_api_key"
value={tmdbApiKey}
private_
mono
placeholder={data.tmdbSettings.hasApiKey ? '••••••••••••••••' : ''}
description={data.tmdbSettings.hasApiKey
? 'Leave blank to keep existing key'
: 'Use the API Read Access Token (not API Key) from themoviedb.org'}
on:input={(e) => {
tmdbApiKey = e.detail;
update('tmdb_api_key', e.detail);
}}
/>
private_ turns the input into a password field with an eye toggle; mono
switches to the monospace font stack (useful for API keys and URLs).
A plain URL input:
<!-- src/routes/settings/general/+page.svelte:659 -->
<FormInput
label="API URL"
name="ai_api_url"
value={aiApiUrl}
type="url"
mono
description="OpenAI-compatible endpoint (e.g., Ollama: http://localhost:11434/v1)"
on:input={(e) => {
aiApiUrl = e.detail;
update('ai_api_url', e.detail);
}}
/>
Inside a modal with compact sizing, FormInput sheds its label and runs
flush with surrounding content when label is omitted.
NumberInput
$ui/form/NumberInput.svelte wraps <input type="number"> and adds
increment/decrement buttons, min/max validation, and a compact mode for
dense form grids. Typical use: retention days, timeouts, thresholds.
<!-- src/routes/settings/general/+page.svelte:472 -->
<NumberInput
name="backup_retention_days"
id="backup_retention_days"
value={backupRetentionDays}
min={1}
max={365}
onchange={(v) => {
backupRetentionDays = v;
update('backup_retention_days', v);
}}
/>
SearchDropdown and DropdownSelect
Two related components:
$ui/form/SearchDropdown.svelteis a searchable combobox. The user types to filter, keyboard arrows navigate, Enter selects. Supports fixed positioning so it renders above modals and other overlay context.$ui/dropdown/DropdownSelect.svelteis the plain<select>replacement: no search box, just click and pick.
DropdownSelect for a small fixed option list:
<!-- src/routes/settings/general/+page.svelte:337 -->
<DropdownSelect
value={uiAlertPosition}
options={alertPositionOptions}
fullWidth
fixed
on:change={(e) => {
uiAlertPosition = e.detail as AlertPosition;
update('ui_alert_position', uiAlertPosition);
}}
/>
SearchDropdown used as a sub-component inside DateInput to pick a year:
<!-- src/lib/client/ui/form/DateInput.svelte:159 -->
<SearchDropdown
value={year}
options={yearOptions}
label="Year"
hideLabel
on:change={(event) => onYearChange(event.detail)}
/>
Use SearchDropdown when the option list is long (movies, profiles,
timezones). Use DropdownSelect when it's short and enumerable (log levels,
sort order).
MarkdownInput
$ui/form/MarkdownInput.svelte is the <textarea> replacement. It adds
a markdown toolbar, a live preview toggle, and auto-grow behavior. Pass
markdown={false} for plain multi-line text without the toolbar.
DateInput and TimeInput
$ui/form/DateInput.svelte composes three SearchDropdowns
(month / day / year) with days-in-month validation so February never accepts
day 30. Value format is YYYY-MM-DD.
$ui/form/TimeInput.svelte is two dropdowns (HH / MM).
Both exist because cross-browser native date/time pickers are a usability nightmare on mobile.
TagInput
$ui/form/TagInput.svelte is a chips-style input. Enter adds a tag;
Backspace on an empty field removes the last one. Supports a NOT: prefix
convention for exclusions (used by custom-format language filters and the
release-title parser).
IconCheckbox
$ui/form/IconCheckbox.svelte is a checkbox with an icon baked in and
configurable colors (hex or CSS variable), plus filled / outline variants.
Used for qualifiers like "exclude", "required", "boost".
KeyValueList, RangeScale, CronInput
Minor but worth knowing:
$ui/form/KeyValueList.svelte: dynamic list of string key / string value pairs. Add / remove rows; used for header maps and env-var style config.$ui/form/RangeScale.svelte: slider for bounded numeric values (score weights, percentages).$ui/cron/CronInput.svelte: structured cron-expression builder with common presets (hourly, daily, weekly) plus a raw-expression escape hatch.
Buttons and Toggles
Button
$ui/button/Button.svelte is the single replacement for every
<button> and action link in the app. Props cover everything raw buttons
need: text, icon (a Lucide component), variant
(primary / secondary / danger / ghost / others), size
(xs / sm / md / lg), loading, disabled, href (renders as <a>),
and type (for form submits). If a tooltip prop is provided, the button
wraps itself in a Tooltip.
A form-submit button with a loading spinner:
<!-- src/routes/settings/general/+page.svelte:270 -->
<Button
text={saving ? 'Saving...' : 'Save'}
icon={saving ? Loader2 : Save}
iconColor="text-blue-600 dark:text-blue-400"
loading={saving}
disabled={saving || !$isDirty}
type="submit"
/>
A compact icon-only action (alert preview in a section header):
<!-- src/routes/settings/general/+page.svelte:290 -->
<Button
icon={CheckCircle}
iconColor="text-green-600 dark:text-green-400"
size="xs"
on:click={() => alertStore.add('success', 'Success alert example.')}
/>
A row action inside a table body:
<!-- src/routes/settings/logs/+page.svelte:359 -->
<Button icon={Copy} size="xs" variant="secondary" on:click={() => copyToClipboard(row.message)} />
Toggle
$ui/toggle/Toggle.svelte is the boolean switch. Emits a change event
with detail being the new boolean. Supports inline label text, a
fullWidth mode for grid cells, and custom colors.
<!-- src/routes/settings/general/+page.svelte:322 -->
<Toggle
label={uiNavIconStyle === 'emoji' ? 'Enabled' : 'Disabled'}
checked={uiNavIconStyle === 'emoji'}
fullWidth
on:change={(e) => {
uiNavIconStyle = e.detail ? 'emoji' : 'lucide';
update('ui_nav_icon_style', uiNavIconStyle);
}}
/>
The toggle is almost always paired with a matching <input type="hidden">
so the form action receives a value for unchecked state (unchecked
checkboxes submit nothing by default):
<Toggle checked={backupEnabled} on:change={...} />
<input type="hidden" name="backup_enabled" value={backupEnabled ? 'on' : ''} />
Hidden inputs are exempt from the no-raw-ui rule specifically to support
this pattern.
And a standalone toggle that just drives local state:
<!-- src/routes/settings/general/+page.svelte:648 -->
<Toggle
label="Enable AI Features"
checked={aiEnabled}
on:change={(e) => {
aiEnabled = e.detail;
update('ai_enabled', e.detail);
}}
/>
SelectableContainer and SelectableRow
$ui/toggle/SelectableContainer.svelte + SelectableRow.svelte are
the bulk-selection pair: a container that manages selection state across a
list of rows, each row showing a checkbox indicator. Used in the import
modal and release-selection flows.
Layout and Containers
Card, CardGrid, ExpandableCard, StickyCard
The card family is the primary content container:
$ui/card/Card.svelteis a surface with padding, optional hover, optionalhref(renders as a link), and aflushmode that removes internal padding when the card is used as a grid child.$ui/card/CardGrid.svelteis a responsive grid (1 / 2 / 3 / 4 / 5 column breakpoints) that lays cards out.$ui/card/ExpandableCard.svelteis a card with a collapsible body and a header that supports a right-slot for inline actions.$ui/card/StickyCard.svelteis a card that pins to the bottom of the viewport, typically used as a save/discard action bar.
A settings page uses ExpandableCard extensively to group related fields
under collapsible sections:
<!-- src/routes/settings/general/+page.svelte:282 -->
<ExpandableCard
title="Interface"
description="Customize the look and feel of the application"
>
<svelte:fragment slot="header-right">
<div class="flex items-center gap-1" on:click|stopPropagation>
<Button icon={CheckCircle} size="xs" on:click={...} />
<!-- more header-right actions -->
</div>
</svelte:fragment>
<div class="px-6 py-4">
<!-- section content -->
</div>
</ExpandableCard>
A Card as a filter-group container:
<!-- src/routes/arr/[id]/upgrades/components/FilterGroup.svelte:68 -->
<Card padding="md" flush={depth === 0}>
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs font-medium">Match</span>
<DropdownSelect value={group.match} options={matchOptions} />
</div>
</div>
</Card>
A CardGrid of repository-style entity tiles is the standard layout for
list pages (databases, profiles, regex library, custom formats).
Dropdown family
Sub-components for building custom menu UIs:
Dropdown.svelte: positioning container with fixed-positioning and hover-bridge support.DropdownHeader.svelte,DropdownFooter.svelte,DropdownItem.svelte: styled section header, footer, and menu item.DropdownFootermirrorsDropdownHeaderbut usesborder-topinstead ofborder-bottomand sits at the end of a menu (e.g. "and 12 more").DropdownSelect.svelte: the<select>replacement (covered under Inputs).CustomGroupManager.svelte: specialized dropdown used by the custom format group editor.
Most route code consumes DropdownSelect; the lower-level pieces are for
building bespoke menus.
DraggableCard
$ui/list/DraggableCard.svelte wraps a card with the drag-and-drop
affordance used for reordering ordered collections (profile qualities,
custom format conditions).
Modals and Overlays
Modal
$ui/modal/Modal.svelte is the <dialog> replacement. It owns
overlay / backdrop handling, Escape-to-close, focus management, and
scroll-lock on <body>. Body content is a slot; footer actions can be the
built-in confirm / cancel pair (via confirmText / cancelText /
confirmDanger props) or custom markup via the footer slot.
A confirm-to-delete modal with built-in footer:
<!-- src/routes/databases/components/InstanceForm.svelte:506 -->
<Modal
open={showDeleteModal}
header="Unlink Database"
bodyMessage={`Are you sure you want to unlink "${instance?.name}"? This action cannot be undone and all local data will be permanently removed.`}
confirmText="Unlink"
cancelText="Cancel"
confirmDanger={true}
on:confirm={() => {
showDeleteModal = false;
const deleteForm = document.getElementById('delete-form');
if (deleteForm instanceof HTMLFormElement) {
deleteForm.requestSubmit();
}
}}
/>
A multi-step import modal with custom body content (opens in
ImportReleasesModal around line 342) uses <div slot="body"> with
conditional step rendering driven by a local step variable.
An add-entity modal that contains a SearchDropdown for picking between
existing entities is at
src/routes/quality-profiles/entity-testing/[databaseId]/components/AddEntityModal.svelte.
DirtyModal, InfoModal, ImportModal, CloneModal, SyncPromptModal
Pre-composed modals for recurring flows:
DirtyModal.svelteis the nav-guard prompt wired to the dirty store; drop one instance per form and it auto-triggers. Seedirty.mdfor details.InfoModal.svelteis a single-action alert ("OK") for success and info messages.ImportModal.svelte,CloneModal.svelte,SyncPromptModal.svelteare specialized wrappers for those specific flows (DB import, entity clone, sync prompt).
Data Display
Table and ExpandableTable
$ui/table/Table.svelte is the <table> replacement. It owns sortable
headers, hover rows, responsive card fallback on narrow viewports, an empty
state, progressive loading, page size, and a per-row actions slot. Columns
are described declaratively.
A log table with sort, hover, compact rows, and row actions:
<!-- src/routes/settings/logs/+page.svelte:347 -->
<Table
data={paginatedLogs}
{columns}
emptyMessage="No logs found"
hoverable={true}
compact={true}
responsive
initialSort={{ key: 'timestamp', direction: defaultSortDirection }}
onSortChange={handleSortChange}
>
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-1">
<Button icon={Copy} size="xs" variant="secondary" on:click={...} />
</div>
</svelte:fragment>
</Table>
Additional call sites worth skimming:
src/routes/settings/security/+page.svelte: activity log with dense rows.src/routes/databases/[id]/conflicts/+page.svelte: conflict table that pairs sort with an expand-detail slot.src/routes/arr/[id]/logs/+page.svelte: arr-specific log table mirroring the Profilarr logs layout.
$ui/table/ExpandableTable.svelte extends Table with a per-row
expand region (click a chevron, get a nested detail view). Used in
entity-testing tables where each row has nested per-release detail.
$ui/table/TableActionButton.svelte is a button styled to match the
dense vertical rhythm of table action cells; prefer it over Button when
the button lives in a table column.
Badge
$ui/badge/Badge.svelte is an inline pill label. Variants cover the
usual palette (accent / neutral / success / warning / danger /
info) plus product-specific (radarr / sonarr). Supports an inline
icon and mono text.
Label
$ui/label/Label.svelte is the lighter-weight text label; use it for
inline tags that don't need a badge's visual weight.
CodeBlock and InlineCode
$ui/code/CodeBlock.svelte: syntax-highlighted multi-line code with theme selection and a copy button.$ui/code/InlineCode.svelte: inline monospace span for references in prose.
A second CodeBlock exists at $ui/display/CodeBlock.svelte alongside
$ui/display/Markdown.svelte; the display/ variants are used by the
help / docs surface while code/ is the general-purpose block. Treat the
code/ pair as the canonical entry point for now.
Navigation
The navigation family is consumed mostly by the app shell (root layout), not by individual routes.
navigation/navbar/navbar.svelte: top bar with logo,themeToggle.svelte, andaccentPicker.svelte.navigation/pageNav/pageNav.svelte: sidebar navigation composed ofgroup.svelte,groupHeader.svelte,groupItem.svelte, plusjobStatus.svelteandversion.sveltefooter widgets.navigation/bottomNav/BottomNav.svelte: mobile bottom tab bar.
Route-level navigation primitives:
-
navigation/tabs/Tabs.svelte: horizontal tab bar with a breadcrumb and an optional back button. Tabs are described as{ label, href, active, icon }objects.<!-- src/routes/regular-expressions/[databaseId]/+page.svelte:148 --> <Tabs {tabs} responsive /> -
navigation/breadcrumb/Breadcrumb.svelte: standalone breadcrumb for pages that don't need tabs. -
navigation/pagination/Pagination.svelte: page-number pagination consumed by tables and card grids.
InlineLink
$ui/link/InlineLink.svelte is a lightweight inline <a> for text-level
links within content. It renders as accent-colored text with an underline on
hover. Use it to link entity names to their detail pages.
Props: href (required), text (required), external (boolean, defaults to
false; adds target="_blank" rel="noopener noreferrer").
<!-- src/routes/quality-profiles/[databaseId]/[id]/scoring/components/ScoringTableDesktop.svelte:87 -->
<InlineLink href="/custom-formats/{databaseId}/{row.id}/general" text={row.name} external />
Feedback and Info
Tooltip
$ui/tooltip/Tooltip.svelte wraps any child in a hover-activated
tooltip. It handles viewport edge detection (flipping position when the
tooltip would overflow) and supports a fullWidth mode for stretching to
fit a parent container.
A badge with conditional tooltip text:
<!-- src/routes/arr/[id]/library/components/MovieCard.svelte:107 -->
<Tooltip text={movie.isProfilarrProfile ? '' : 'Not managed by Profilarr'} position="top">
<Badge
variant={movie.isProfilarrProfile ? 'accent' : 'warning'}
icon={movie.isProfilarrProfile ? null : CircleAlert}
mono
/>
</Tooltip>
Empty text disables the tooltip without an {#if} wrapper. The tooltip
is also baked into Button via the tooltip prop; prefer that over
manually wrapping a <Button>.
EmptyState
$ui/state/EmptyState.svelte is the "no data yet" empty-view surface:
icon, title, description, plus an optional CTA button (with buttonText,
buttonHref, buttonIcon, onboarding hook).
<!-- src/routes/databases/+page.svelte:49 -->
<EmptyState
icon={Database}
title="No Databases Linked"
description="Link a Profilarr Compliant Database to get started with profile management."
buttonText="Link Database"
buttonHref="/databases/new"
buttonIcon={Plus}
onboarding="db-add"
/>
Used on every list page when the underlying collection is empty. The
onboarding prop ties the CTA into the cutscene system (see
./cutscene.md).
HelpButton
$ui/help/HelpButton.svelte is the floating / navbar help button that
opens the help modal. Variants: fab (floating) or navbar (inline).
Filters
$ui/filter/FilterTag.svelte: active-filter chip with a clear button.$ui/filter/SmartFilterBar.svelte: multi-facet filter toolbar that combines search, filter chips, and saved-filter presets. Used on list-heavy pages (arr library, custom formats).
Actions Bar
The actions/ family builds merged-border toolbar groups, typically at the
top of a list page:
ActionsBar.svelte: horizontal container that merges children's borders so they look like a single unit.ActionButton.svelte,ActionInput.svelte: button and input styled to fit inside the bar.SearchAction.svelte: search field optimized for the bar (with clear / submit handling).SearchFilterAction.svelte: filter button with an indicator for active filters.SearchModeToggle.svelte: toggle between search modes (e.g., "fuzzy" vs. "exact").ViewToggle.svelte: list / grid view switcher used on pages that support both layouts.
Arr-Specific
Components scoped to Radarr / Sonarr semantics rather than generic UI:
$ui/arr/Score.svelte: custom-format score display with positive / negative coloring.$ui/arr/CustomFormatBadge.svelte: badge styled specifically for custom formats (tight sizing, color-coded by score).$ui/arr/ProgressIndicator.svelte: progress bar for long-running arr operations (sync, bulk upgrade).
Semantic Tokens and Theming (Planned)
Status: not started. Tracked in issue #298.
Today, every component in src/lib/client/ui/ ships with hardcoded
Tailwind palette classes paired with dark: variants:
<button class="bg-white border-neutral-300 text-neutral-700
dark:bg-neutral-800 dark:border-neutral-700 dark:text-neutral-200">
This works, but it pins the app to exactly two themes (light and dark) and duplicates every color decision across two class strings.
The plan is to migrate to semantic CSS variable tokens so components
reference intent-level names (bg-surface, text-text, border-border)
instead of palette values. Each theme then lives in its own CSS file and
swaps the token values:
<button class="bg-surface border-border text-text">