Build consistent, cross-framework component libraries where Phoenix function components and Svelte 5 components share a visual language through Tailwind design tokens. Use this skill whenever building UI components for a Phoenix LiveView + Svelte stack, setting up a component library or design system, creating variant-based component APIs (like button/1 with :variant and :size attrs), working with slots/snippets patterns, or establishing shared Tailwind token systems. Also trigger when the user mentions "component library", "design system", "shared components", "variant API", "token system", or is building Phoenix function components alongside Svelte components and needs them to look and behave consistently.
Install
npx skillscat add hwatkins/my-skills/component-design-system Install via the SkillsCat registry.
Component Design System
Architecture patterns for building a consistent component library where Phoenix function
components and Svelte 5 components live side by side, sharing visual language through
a unified Tailwind design token system.
When You Need This Skill
You're working in a stack where Phoenix LiveView renders most of the UI via HEEx templates
and function components, but islands of interactivity are handled by Svelte 5 components
(mounted via LiveSvelte). The challenge: both sides need to produce visually identical
output from a single source of truth for colors, spacing, typography, and component
structure.
Core Architecture
The system has three layers:
- Token Layer — Tailwind CSS
@themevariables that define the design language - Component API Layer — Variant/size/slot patterns implemented per-framework
- Contract Layer — Shared conventions that keep both frameworks aligned
Core Principles
- One source of truth for tokens — Tailwind config defines colors, spacing, typography, radii, shadows. Both Phoenix and Svelte components consume the same tokens.
- Phoenix components for server-rendered UI — buttons, badges, form inputs, layout primitives, flash messages, modals, tables.
- Svelte components for interactive UI — rich editors, data grids, charts, drag-and-drop, anything needing local reactive state.
- Consistent API conventions — same variant names, same size scale, same naming patterns across both worlds.
- Composition over configuration — prefer slots/snippets for flexible content over deeply nested option maps.
Design Token System
Design tokens follow a three-tier hierarchy:
- Primitive tokens — Raw color, spacing, and type values
- Semantic tokens — Map primitives to intent (what components reference)
- Component tokens — Scoped to a specific component when the semantic layer isn't specific enough
Token Layers and Naming
Primitive Tokens (Raw Values)
@theme {
--color-blue-50: oklch(97% 0.02 250);
--color-blue-500: oklch(60% 0.22 260);
--color-blue-600: oklch(55% 0.22 260);
--color-blue-900: oklch(25% 0.12 260);
--color-gray-50: oklch(98% 0.005 260);
--color-gray-200: oklch(90% 0.01 260);
--color-gray-500: oklch(55% 0.02 260);
--color-gray-900: oklch(20% 0.02 260);
--color-red-500: oklch(60% 0.25 25);
--color-red-600: oklch(53% 0.25 25);
}Semantic Tokens (Intent-Based)
Map primitives to semantic meaning. These are what components reference:
@theme {
--color-primary: var(--color-blue-500);
--color-primary-hover: var(--color-blue-600);
--color-destructive: var(--color-red-500);
--color-destructive-hover: var(--color-red-600);
--color-foreground: var(--color-gray-900);
--color-muted-foreground: var(--color-gray-500);
--color-muted: var(--color-gray-50);
--color-surface: oklch(99% 0.005 260);
--color-surface-raised: oklch(100% 0 0);
--color-border: var(--color-gray-200);
--color-ring: var(--color-blue-500);
}Component Tokens (Scoped to a Component)
For complex components where the semantic layer isn't specific enough.
These go in :root, NOT in @theme — you don't want Tailwind to generate
utility classes like bg-btn-primary-bg:
:root {
--btn-primary-bg: var(--color-primary);
--btn-primary-fg: white;
--btn-primary-hover-bg: var(--color-primary-hover);
--btn-size-sm-px: var(--spacing-sm);
--btn-size-sm-py: var(--spacing-xs);
--btn-size-md-px: var(--spacing-lg);
--btn-size-md-py: var(--spacing-sm);
--btn-size-lg-px: var(--spacing-xl);
--btn-size-lg-py: var(--spacing-md);
--input-border: var(--color-border);
--input-focus-ring: var(--color-ring);
--input-bg: var(--color-surface);
}Tailwind v4 @theme Configuration
Tailwind v4 uses CSS-first configuration via the @theme directive. Each namespace
generates corresponding utility classes:
/* assets/css/tokens.css — the single source of truth */
@import "tailwindcss";
@theme {
/* Colors → bg-*, text-*, border-*, ring-* utilities */
--color-primary: oklch(65% 0.25 260);
--color-surface: oklch(99% 0.005 260);
/* Spacing → p-*, m-*, gap-*, w-*, h-* utilities */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 0.75rem;
--spacing-lg: 1rem;
--spacing-xl: 1.5rem;
--spacing-2xl: 2rem;
--spacing-3xl: 3rem;
/* Radii → rounded-* utilities */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-full: 9999px;
/* Fonts → font-* utilities */
--font-display: "Instrument Sans", system-ui, sans-serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
/* Font sizes → text-* utilities */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
/* Shadows → shadow-* utilities */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
/* Breakpoints → responsive variants */
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
}All tokens are simultaneously available as CSS variables for use in Svelte <style>
blocks or arbitrary Tailwind values.
Dark Mode and Theming
Use @theme { initial } to declare themeable token names, then set values per-theme
in :root. This keeps utility class names stable while values change:
@theme {
/* Declare names — values come from :root */
--color-background: initial;
--color-foreground: initial;
--color-surface: initial;
--color-muted: initial;
--color-primary: initial;
}
/* Light theme (default) */
:root {
--color-background: oklch(99% 0 0);
--color-foreground: oklch(15% 0.02 260);
--color-surface: oklch(100% 0 0);
--color-muted: oklch(96% 0.005 260);
--color-primary: oklch(55% 0.25 260);
}
/* System preference dark mode */
@media (prefers-color-scheme: dark) {
:root {
--color-background: oklch(15% 0.02 260);
--color-foreground: oklch(95% 0.005 260);
--color-surface: oklch(20% 0.02 260);
--color-muted: oklch(25% 0.02 260);
--color-primary: oklch(70% 0.22 260);
}
}
/* Class-based dark mode (for manual toggle) */
.dark {
--color-background: oklch(15% 0.02 260);
--color-foreground: oklch(95% 0.005 260);
--color-surface: oklch(20% 0.02 260);
--color-muted: oklch(25% 0.02 260);
--color-primary: oklch(70% 0.22 260);
}This means bg-background, text-foreground, etc. automatically adapt to the current
theme in both Phoenix templates and Svelte components.
Consuming Tokens
In Phoenix HEEx Templates
Use Tailwind utility classes directly:
<div class="bg-surface rounded-lg p-lg shadow-md">
<h2 class="text-xl font-display text-foreground">Title</h2>
<p class="text-sm text-muted-foreground">Description</p>
</div>For computed/conditional styles, use arbitrary value syntax:
<div class="px-[--spacing-lg] py-[--spacing-md]">
<!-- When you need a token value that doesn't have a perfect utility -->
</div>In Svelte Components
Use the same Tailwind classes in markup. For Svelte <style> blocks, reference
the CSS variables directly:
<div class="bg-surface rounded-lg p-lg shadow-md">
<h2 class="text-xl font-display text-foreground">Title</h2>
</div>
<style>
.custom-layout {
padding: var(--spacing-lg);
gap: var(--spacing-md);
border-radius: var(--radius-lg);
}
</style>In JavaScript/TypeScript (Runtime Access)
For animations, canvas rendering, or dynamic styles:
const primary = getComputedStyle(document.documentElement)
.getPropertyValue('--color-primary');Phoenix Function Component Patterns
The Variant + Size Pattern
The variant pattern uses attr/3 declarations with constrained values and private helper
functions for class resolution. This gives compile-time validation and clean separation
of concerns.
defmodule MyAppWeb.UI.Button do
use Phoenix.Component
@doc """
Renders a button with variant and size support.
## Examples
<.button>Click me</.button>
<.button variant="outline" size="lg">Big outline</.button>
<.button variant="destructive" phx-click="delete">Remove</.button>
"""
attr :variant, :string,
values: ~w(primary secondary outline ghost destructive),
default: "primary",
doc: "Visual style variant"
attr :size, :string,
values: ~w(sm md lg icon),
default: "md",
doc: "Size preset"
attr :disabled, :boolean, default: false
attr :class, :string, default: nil
attr :rest, :global,
include: ~w(type name form phx-click phx-disable-with navigate patch href)
slot :inner_block, required: true
slot :icon_left, doc: "Icon rendered before the label"
slot :icon_right, doc: "Icon rendered after the label"
def button(assigns) do
~H"""
<button
class={[
base_classes(),
variant_classes(@variant),
size_classes(@size),
@class
]}
disabled={@disabled}
{@rest}
>
<span :if={@icon_left != []} class="shrink-0">{render_slot(@icon_left)}</span>
{render_slot(@inner_block)}
<span :if={@icon_right != []} class="shrink-0">{render_slot(@icon_right)}</span>
</button>
"""
end
defp base_classes do
"inline-flex items-center justify-center gap-2 font-medium rounded-md " <>
"transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 " <>
"focus-visible:outline-primary disabled:opacity-50 disabled:pointer-events-none"
end
defp variant_classes("primary"), do: "bg-[--btn-primary-bg] text-[--btn-primary-fg] hover:bg-primary-hover active:bg-primary-active"
defp variant_classes("secondary"), do: "bg-muted text-foreground hover:bg-muted/80"
defp variant_classes("outline"), do: "border border-border text-foreground hover:bg-muted"
defp variant_classes("ghost"), do: "text-foreground hover:bg-muted"
defp variant_classes("destructive"), do: "bg-destructive text-white hover:bg-destructive/90"
defp size_classes("sm"), do: "text-sm h-8 px-[--btn-size-sm-px] py-[--btn-size-sm-py]"
defp size_classes("md"), do: "text-sm h-10 px-[--btn-size-md-px] py-[--btn-size-md-py]"
defp size_classes("lg"), do: "text-base h-12 px-[--btn-size-lg-px] py-[--btn-size-lg-py]"
defp size_classes("icon"), do: "size-10"
endWhy Pattern-Matched Functions for Variants
Using defp variant_classes("primary") over a map lookup:
- Compiler catches typos in variant values
- Each clause is self-contained and easy to read
- Works naturally with Phoenix's class list merging (list of strings, nils filtered)
- Easy to extend without touching other variants
Size classes use Tailwind's arbitrary property syntax (px-[--btn-size-sm-px])
to reference the component tokens from :root. This means changing button padding
requires editing only tokens.css.
Usage
<.button>Save</.button>
<.button variant="secondary" size="sm">Cancel</.button>
<.button variant="destructive" phx-click="delete" data-confirm="Are you sure?">
Delete
</.button>
<.button variant="outline" size="lg">
<:icon_left><.icon name="hero-plus" /></:icon_left>
Add Item
</.button>
<.button variant="ghost" class="w-full">Full Width Ghost</.button>Named Slots for Complex Layouts
slot :header
slot :inner_block, required: true
slot :footer
attr :padding, :string, values: ~w(none sm md lg), default: "md"
attr :rest, :global
def card(assigns) do
~H"""
<div class="rounded-lg border border-border bg-surface-raised shadow-sm">
<div :if={@header != []} class="border-b border-border px-lg py-md">
{render_slot(@header)}
</div>
<div class={padding_classes(@padding)}>
{render_slot(@inner_block)}
</div>
<div :if={@footer != []} class="border-t border-border px-lg py-md bg-muted/50">
{render_slot(@footer)}
</div>
</div>
"""
end
defp padding_classes("none"), do: ""
defp padding_classes("sm"), do: "p-sm"
defp padding_classes("md"), do: "p-lg"
defp padding_classes("lg"), do: "p-xl"<.card>
<:header>
<h3 class="font-semibold">Settings</h3>
</:header>
<p>Card content here.</p>
<:footer>
<.button size="sm">Save</.button>
</:footer>
</.card>Render Slot with Fallback
<div class="modal-title">
{render_slot(@title) || "Untitled"}
</div>The Table Component with Slot Attributes
Named slots can declare their own attributes. This is the most powerful slot pattern —
it enables the caller to define structure (columns) while the component handles rendering
(iteration, layout).
attr :rows, :list, required: true
attr :row_id, :fun, default: &Phoenix.Param.to_param/1
attr :row_click, :any, default: nil
slot :col, required: true do
attr :label, :string, required: true
attr :class, :string
end
def data_table(assigns) do
~H"""
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b-2 border-border">
<th
:for={col <- @col}
class={["px-lg py-sm text-left font-semibold text-muted-foreground", col[:class]]}
>
{col.label}
</th>
</tr>
</thead>
<tbody>
<tr
:for={row <- @rows}
id={"row-#{@row_id.(row)}"}
class={["border-b border-border hover:bg-muted/50 transition-colors", @row_click && "cursor-pointer"]}
phx-click={@row_click && @row_click.(row)}
>
<td :for={col <- @col} class={["px-lg py-md", col[:class]]}>
{render_slot(col, row)}
</td>
</tr>
</tbody>
</table>
</div>
"""
endUsage with :let to pass row data back:
<.data_table rows={@users}>
<:col :let={user} label="Name">
<span class="font-medium">{user.name}</span>
</:col>
<:col :let={user} label="Email" class="text-muted-foreground">
{user.email}
</:col>
<:col :let={user} label="Actions" class="text-right">
<.button size="sm" variant="ghost" phx-click="edit" phx-value-id={user.id}>
Edit
</.button>
</:col>
</.data_table>Badge Component
attr :variant, :atom,
values: [:default, :success, :warning, :error, :info],
default: :default
attr :size, :atom, values: [:sm, :md], default: :sm
attr :rest, :global
slot :inner_block, required: true
def badge(assigns) do
~H"""
<span
class={[
"inline-flex items-center font-medium rounded-full",
badge_variant(@variant),
badge_size(@size)
]}
{@rest}
>
{render_slot(@inner_block)}
</span>
"""
end
defp badge_variant(:default), do: "bg-gray-100 text-gray-700"
defp badge_variant(:success), do: "bg-green-100 text-green-700"
defp badge_variant(:warning), do: "bg-yellow-100 text-yellow-800"
defp badge_variant(:error), do: "bg-red-100 text-red-700"
defp badge_variant(:info), do: "bg-blue-100 text-blue-700"
defp badge_size(:sm), do: "px-2 py-0.5 text-xs"
defp badge_size(:md), do: "px-2.5 py-1 text-sm"Form Input with Error States
attr :field, Phoenix.HTML.FormField, required: true
attr :type, :string, default: "text"
attr :label, :string, default: nil
attr :placeholder, :string, default: nil
attr :rest, :global
def input(assigns) do
~H"""
<div>
<label :if={@label} for={@field.id} class="block text-sm font-medium text-text-primary mb-1">
{@label}
</label>
<input
type={@type}
id={@field.id}
name={@field.name}
value={@field.value}
placeholder={@placeholder}
class={[
"w-full rounded border px-3 py-2 text-sm transition-colors",
"focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500",
@field.errors == [] && "border-border",
@field.errors != [] && "border-red-500 focus:ring-red-500"
]}
{@rest}
/>
<p :for={error <- @field.errors} class="mt-1 text-xs text-red-600">
{translate_error(error)}
</p>
</div>
"""
endCompound Components
For complex UI patterns (dropdown menus, accordions, tabs), compose multiple function
components that share a namespace:
defmodule MyAppWeb.UI.Tabs do
use Phoenix.Component
attr :default, :string, required: true, doc: "ID of the initially active tab"
slot :inner_block, required: true
def tabs(assigns) do
~H"""
<div id="tabs" phx-hook="Tabs" data-default={@default}>
{render_slot(@inner_block)}
</div>
"""
end
attr :id, :string, required: true
slot :inner_block, required: true
def tab_trigger(assigns) do
~H"""
<button
role="tab"
data-tab-trigger={@id}
class="px-lg py-sm text-sm font-medium text-muted-foreground
data-[active]:text-foreground data-[active]:border-b-2
data-[active]:border-primary transition-colors"
>
{render_slot(@inner_block)}
</button>
"""
end
attr :id, :string, required: true
slot :inner_block, required: true
def tab_content(assigns) do
~H"""
<div role="tabpanel" data-tab-content={@id} class="hidden data-[active]:block py-lg">
{render_slot(@inner_block)}
</div>
"""
end
end<.tabs default="overview">
<div class="flex gap-xs border-b border-border">
<.tab_trigger id="overview">Overview</.tab_trigger>
<.tab_trigger id="analytics">Analytics</.tab_trigger>
</div>
<.tab_content id="overview">Overview content...</.tab_content>
<.tab_content id="analytics">Analytics content...</.tab_content>
</.tabs>Global Attribute Forwarding
Use attr :rest, :global to forward HTML attributes and Phoenix-specific bindings.
Always specify include: to document which global attrs the component accepts:
# For interactive elements
attr :rest, :global, include: ~w(phx-click phx-target phx-value-id)
# For navigation elements
attr :rest, :global, include: ~w(navigate patch href method)
# For form elements
attr :rest, :global, include: ~w(
name form autocomplete placeholder required
min max minlength maxlength pattern
phx-change phx-blur phx-focus phx-debounce
)LiveComponent vs Function Component
Use function components for:
- Stateless rendering with variant/slot APIs
- All design system components (buttons, inputs, cards, modals)
- Any component that doesn't need its own event handling
Use LiveComponent for:
- Components that need their own
handle_eventcallbacks - Components with internal state that changes independently of the parent
- Reusable widgets that encapsulate both state and presentation
The design system should be composed entirely of function components. LiveComponents
are application-level, not design-system-level.
Svelte Component Patterns
Props with $props() and TypeScript
Svelte 5 uses the $props() rune for all prop declarations. For design system
components, always type your props explicitly:
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive";
size?: "sm" | "md" | "lg";
disabled?: boolean;
class?: string;
children: Snippet;
onclick?: (e: MouseEvent) => void;
}
let {
variant = "primary",
size = "md",
disabled = false,
class: className = "",
children,
onclick,
}: Props = $props();
</script>Key patterns:
- Rename
classtoclassNamevia destructuring (classis reserved in JS) - Use union types for variant values — mirrors Phoenix's
values: ~w(...)constraint - Default values in destructuring — mirrors Phoenix's
default:option Snippettype from"svelte"for content slots- Event handlers are plain callback props (no more
createEventDispatcher)
Variant Pattern with Type Safety
Mirror Phoenix's variant helper functions with TypeScript Record objects.
The class strings must match the Phoenix defp variant_classes/1 exactly:
<!-- assets/svelte/Button.svelte -->
<script lang="ts">
import type { Snippet } from "svelte";
type Variant = "primary" | "secondary" | "outline" | "ghost" | "destructive";
type Size = "sm" | "md" | "lg" | "icon";
interface Props {
variant?: Variant;
size?: Size;
disabled?: boolean;
class?: string;
children: Snippet;
iconLeft?: Snippet;
iconRight?: Snippet;
onclick?: (e: MouseEvent) => void;
}
let {
variant = "primary",
size = "md",
disabled = false,
class: className = "",
children,
iconLeft,
iconRight,
onclick,
}: Props = $props();
const VARIANT: Record<Variant, string> = {
primary: "bg-[--btn-primary-bg] text-[--btn-primary-fg] hover:bg-primary-hover active:bg-primary-active",
secondary: "bg-muted text-foreground hover:bg-muted/80",
outline: "border border-border text-foreground hover:bg-muted",
ghost: "text-foreground hover:bg-muted",
destructive: "bg-destructive text-white hover:bg-destructive/90",
};
const SIZE: Record<Size, string> = {
sm: "text-sm h-8 px-[--btn-size-sm-px] py-[--btn-size-sm-py]",
md: "text-sm h-10 px-[--btn-size-md-px] py-[--btn-size-md-py]",
lg: "text-base h-12 px-[--btn-size-lg-px] py-[--btn-size-lg-py]",
icon: "size-10",
};
const BASE = `inline-flex items-center justify-center gap-2 font-medium rounded-md
transition-colors focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-primary disabled:opacity-50 disabled:pointer-events-none`;
</script>
<button
class="{BASE} {VARIANT[variant]} {SIZE[size]} {className}"
{disabled}
{onclick}
>
{#if iconLeft}
<span class="shrink-0">{@render iconLeft()}</span>
{/if}
{@render children()}
{#if iconRight}
<span class="shrink-0">{@render iconRight()}</span>
{/if}
</button>Snippet Composition (Replacing Slots)
Svelte 5 replaces slots with snippets. Snippets are more explicit and flexible.
Named Snippets (Card Example)
<!-- assets/svelte/Card.svelte -->
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
header?: Snippet;
children: Snippet;
footer?: Snippet;
}
let { header, children, footer }: Props = $props();
</script>
<div class="rounded-lg border border-border bg-surface-raised shadow-sm">
{#if header}
<div class="border-b border-border px-lg py-md">
{@render header()}
</div>
{/if}
<div class="px-lg py-lg">
{@render children()}
</div>
{#if footer}
<div class="border-t border-border px-lg py-md bg-muted/50">
{@render footer()}
</div>
{/if}
</div><!-- Usage -->
<Card>
{#snippet header()}
<h3 class="font-semibold">Settings</h3>
{/snippet}
<p>Card content here.</p>
{#snippet footer()}
<Button size="sm">Save</Button>
{/snippet}
</Card>Snippets with Parameters (Replaces :let)
This is the Svelte equivalent of Phoenix's render_slot(@col, row) with :let={value}:
<script lang="ts" generics="T">
import type { Snippet } from "svelte";
interface Props<T> {
items: T[];
children: Snippet<[T, number]>;
empty?: Snippet;
}
let { items, children, empty }: Props<T> = $props();
</script>
{#if items.length === 0 && empty}
{@render empty()}
{:else}
{#each items as item, index}
{@render children(item, index)}
{/each}
{/if}<!-- Usage -->
<List items={users}>
{#snippet children(user, i)}
<div class="flex items-center gap-md">
<span class="text-muted-foreground">{i + 1}.</span>
<span>{user.name}</span>
</div>
{/snippet}
{#snippet empty()}
<p class="text-muted-foreground">No users found.</p>
{/snippet}
</List>Event Handling
Svelte 5 uses plain callback props instead of on: directives:
<!-- Component definition -->
<script lang="ts">
let { onclick, onkeydown, ...rest }: {
onclick?: (e: MouseEvent) => void;
onkeydown?: (e: KeyboardEvent) => void;
} = $props();
</script>
<button {onclick} {onkeydown}>...</button>
<!-- Usage -->
<Button onclick={() => console.log("clicked")}>Click</Button>For forwarding all event handlers, use rest props:
<script lang="ts">
let { children, class: className, ...rest } = $props();
</script>
<button class={className} {...rest}>{@render children()}</button>Generic Data Table
<!-- assets/svelte/DataTable.svelte -->
<script lang="ts" generics="T">
import type { Snippet } from "svelte";
interface Column<T> {
label: string;
class?: string;
cell: Snippet<[T]>;
}
interface Props<T> {
rows: T[];
columns: Column<T>[];
rowId: (row: T) => string;
}
let { rows, columns, rowId }: Props<T> = $props();
</script>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b-2 border-border">
{#each columns as col}
<th class="px-lg py-sm text-left font-semibold text-muted-foreground {col.class ?? ''}">{col.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row (rowId(row))}
<tr class="border-b border-border hover:bg-muted/50 transition-colors">
{#each columns as col}
<td class="px-lg py-md {col.class ?? ''}">{@render col.cell(row)}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>Cross-Framework Component Contract
Alignment Checklist
When building a component that exists in both Phoenix and Svelte, verify:
- Same variant names: Both use identical string values (e.g., "primary", "outline")
- Same size names: Both use identical string values (e.g., "sm", "md", "lg")
- Same HTML structure: Same element hierarchy, same semantic elements
- Same class strings: Variant→class and size→class mappings are character-identical
- Same ARIA attributes: role, aria-label, aria-expanded, etc. are consistent
- Same slot→snippet mapping: Each Phoenix slot has a corresponding Svelte snippet prop
- Same defaults: Default variant, size, and boolean values match
- Same token usage: Neither framework hardcodes values that should come from tokens
Mapping Phoenix to Svelte Concepts
| Phoenix | Svelte 5 | Notes |
|---|---|---|
attr :variant, :string, values: ~w(a b) |
variant?: "a" | "b" |
Same values, different syntax |
attr :disabled, :boolean, default: false |
disabled?: boolean (default in destructuring) |
|
attr :class, :string, default: nil |
class?: string (rename to className) |
|
attr :rest, :global |
...rest spread |
|
slot :inner_block |
children: Snippet |
|
slot :header |
header?: Snippet |
|
slot :col do attr :label, :string end |
Column<T> interface with label field |
|
render_slot(@col, row) |
{@render col.cell(row)} |
Data passed back to caller |
:if={@header != []} |
{#if header} |
Optional slot check |
phx-click={JS.push("event")} |
onclick callback prop |
Different event systems |
@class (class list merge) |
Template literal class string |
Variant Values Must Match
Define variant values once and use them everywhere:
# lib/my_app_web/components/variants.ex
defmodule MyAppWeb.Variants do
@button_variants ~w(primary secondary outline ghost destructive)
@sizes ~w(sm md lg icon)
@badge_variants ~w(default success warning error info)
def button_variants, do: @button_variants
def sizes, do: @sizes
def badge_variants, do: @badge_variants
endClass Maps Must Match (Shared Variant Maps)
The Tailwind classes for each variant must be identical in both Phoenix and Svelte.
Extract them to a shared TypeScript module that serves as documentation and the
single reference when updating Phoenix defp functions:
// assets/svelte/lib/variants.ts
export const buttonVariants = {
primary: "bg-[--btn-primary-bg] text-[--btn-primary-fg] hover:bg-primary-hover active:bg-primary-active",
secondary: "bg-muted text-foreground hover:bg-muted/80",
outline: "border border-border text-foreground hover:bg-muted",
ghost: "text-foreground hover:bg-muted",
destructive: "bg-destructive text-white hover:bg-destructive/90",
} as const;
export const buttonSizes = {
sm: "text-sm h-8 px-[--btn-size-sm-px] py-[--btn-size-sm-py]",
md: "text-sm h-10 px-[--btn-size-md-px] py-[--btn-size-md-py]",
lg: "text-base h-12 px-[--btn-size-lg-px] py-[--btn-size-lg-py]",
icon: "size-10",
} as const;
export type ButtonVariant = keyof typeof buttonVariants;
export type ButtonSize = keyof typeof buttonSizes;
export type BadgeVariant = "default" | "success" | "warning" | "error" | "info";Class String Synchronization Strategy
The class strings are the real contract. To keep them in sync:
Option A: Copy-and-verify (Recommended for small systems)
Maintain the class strings in both frameworks. When updating one, search for the same
strings in the other and update there too. Add a comment at the top of each variant map:
# Synced with: assets/svelte/components/Button.svelte
defp variant_classes("primary"), do: "bg-[--btn-primary-bg] text-[--btn-primary-fg] hover:bg-primary-hover"// Synced with: lib/my_app_web/components/ui/button.ex
const VARIANT = {
primary: "bg-[--btn-primary-bg] text-[--btn-primary-fg] hover:bg-primary-hover",
};Option B: Shared JSON (For larger systems)
Define variants in a JSON file consumed by both:
// assets/shared/button-variants.json
{
"variants": {
"primary": "bg-[--btn-primary-bg] text-[--btn-primary-fg] hover:bg-primary-hover",
"secondary": "bg-muted text-foreground hover:bg-muted/80"
},
"sizes": {
"sm": "text-sm h-8 px-[--btn-size-sm-px] py-[--btn-size-sm-py]",
"md": "text-sm h-10 px-[--btn-size-md-px] py-[--btn-size-md-py]"
}
}Phoenix reads this at compile time via a module attribute. Svelte imports it directly.
This guarantees identical strings but adds build complexity.
Option C: Generated from tokens (For design-system-at-scale)
Write a code generator that produces both button.ex helper functions andbutton-variants.ts from a single YAML/TOML component spec. Only worthwhile for
very large component libraries.
Common Components to Build First
Priority order for a Phoenix + Svelte design system:
- Button — The canonical variant/size/slot component. Template for all others.
- Input/TextField — Form fields with label, error, helper text. Phoenix needs for forms; Svelte for interactive widgets.
- Card — Header/body/footer slot pattern. Tests named slot alignment.
- Badge — Simple variant-only component. Quick to build, validates token usage.
- Alert — Variant + icon + dismiss slot. Tests interactive patterns.
- Modal/Dialog — Complex overlay with backdrop, header, body, footer, close. Tests JS interop.
- DataTable — Advanced slot-with-attributes pattern. Tests data rendering.
- Dropdown/Select — Tests compound component patterns and state management.
Handling Interactive Behavior Differences
Phoenix and Svelte handle interactivity differently. The design system defines the
visual contract (classes, structure) while each framework handles behavior its own way:
| Behavior | Phoenix | Svelte |
|---|---|---|
| Show/hide | JS.show() / JS.hide() with transitions |
{#if} with transition: directive |
| Toggle | JS.toggle_class() |
$state boolean + conditional classes |
| Dropdown open | JS.toggle() + phx-click-away |
$state + onclick outside handler |
| Form validation | phx-change → server → re-render |
$state + local validation |
| Navigation | <.link navigate={...}> |
<a> or framework router |
The visual output (what classes are applied, what HTML structure exists) should be
identical. The mechanism for arriving at that state differs per framework.
File Organization
lib/my_app_web/
├── components/
│ ├── core_components.ex # Phoenix function components (button, badge, input, card, table)
│ ├── layouts.ex # Layout components (app shell, sidebar, nav)
│ └── ui/
│ ├── button.ex # Complex components get own modules
│ ├── data_table.ex
│ └── form_fields.ex
assets/
├── css/
│ ├── tokens.css # @theme design tokens (THE source of truth)
│ └── app.css # Imports tokens.css + tailwindcss
├── svelte/
│ ├── components/
│ │ ├── Button.svelte # Mirrors button/1 (only for use inside other Svelte components)
│ │ ├── DataGrid.svelte # Complex interactive table
│ │ ├── RichEditor.svelte # TipTap/ProseMirror wrapper
│ │ ├── Chart.svelte # Chart.js/D3 wrapper
│ │ └── DragDrop.svelte # Sortable list
│ └── lib/
│ └── variants.ts # Shared variant/size class maps (optional)
├── js/
│ ├── lib/
│ │ └── component-classes.ts # Shared class maps
│ └── types/
│ └── variants.ts # Shared type definitionsWhen Phoenix vs. When Svelte
| Use Phoenix Function Component | Use Svelte Component |
|---|---|
| Static or server-driven content | Complex client-side interactivity |
| Buttons, badges, inputs, cards | Rich text editors, data grids |
| Modals (with JS commands) | Drag-and-drop with state |
| Tables with simple data | Charts with animations |
| Flash messages, alerts | Multi-step wizards with local state |
| Navigation, breadcrumbs | Real-time collaborative features |
| Forms (LiveView handles state) | File upload with preview/crop |
Rule of thumb: If the component's behavior can be expressed with phx-* bindings and JS commands, use Phoenix. If it needs reactive local state or complex DOM manipulation, use Svelte.
Anti-Patterns
Duplicating components across both systems
❌ Button.svelte AND button/1 in core_components.ex doing the same thing
✅ Use Phoenix button/1 for server-rendered buttons (99% of cases)
Use Svelte Button only inside other Svelte components that need itInconsistent variant names
❌ Phoenix: :primary, :secondary | Svelte: "main", "alt"
✅ Same names everywhere: primary, secondary, ghost, destructiveHardcoding colors instead of using tokens
❌ class="bg-blue-600" (what blue? whose blue?)
✅ class="bg-primary" (the primary color, defined once in tokens.css @theme)Skipping :global on Phoenix components
# ❌ No way to add phx-click, class, or data-* attributes
attr :variant, :atom, default: :primary
def button(assigns) do ...
# ✅ Accept global attributes
attr :rest, :global
def button(assigns) do
~H"""<button {@rest}>...</button>"""
endOver-engineering the shared layer
❌ Building a code generator that outputs both Phoenix and Svelte from a DSL
✅ Keep it simple: shared tokens.css + matching class maps + same naming conventionsBuilding New Components: The Process
When creating a new component for the design system:
Define the API contract first. Write out variants, sizes, and slots on paper.
Both Phoenix and Svelte versions must accept the same props/attrs (with framework
idioms for naming).Extract Tailwind class maps. Build variant and size class maps as shared
constants. In Phoenix these aredefpfunctions. In Svelte these areRecord
objects. The string values must be identical.Establish the DOM structure. Both versions should render the same HTML element
hierarchy with the same class patterns. Semantic HTML first, then ARIA attributes.Implement slots/snippets. Map Phoenix named slots to Svelte snippet props.
Optional slots in Phoenix (@foo != []) become optional snippet props in Svelte
(if foo).Test visual parity. Render both versions side by side. They should be pixel-
identical because they share the same tokens and class strings.
Key Principles
Tokens are the contract. Never hardcode hex colors or pixel values in components.
Always reference Tailwind token utilities (bg-primary, px-lg) or CSS variables
(var(--color-primary)).
Class strings are the API. The variant→class mapping is the real interface between
the two frameworks. Keep these maps identical. If you update one, update both.
Prefer composition over configuration. Use slots (Phoenix) and snippets (Svelte)
for flexible content rather than adding more and more props. A card/1 with :header,:inner_block, and :footer slots is more composable than one with 15 content attrs.
Use attr declarations and TypeScript types. Phoenix's attr/3 and slot/3
macros give compile-time warnings. Svelte 5's typed $props() gives editor
IntelliSense. Both enforce the component contract.
Don't abstract too early. Start with concrete components. Extract shared patterns
(like variant class maps to a shared file) only when you have three or more components
using the same pattern.
Related Skills
- frontend-tailwind: Tailwind utility patterns, responsive design, dark mode
- frontend-design: Aesthetic direction, typography, color theory, motion principles
- svelte-core: LiveSvelte integration,
live.pushEvent, SSR, reactivity - elixir-liveview: LiveView lifecycle, streams, forms
- liveview-js-interop: JS commands for component behavior (show/hide, transitions)