Type-safe Pines UI components for Elementary — the Alpine.js + Tailwind UI library, rendered directly from Swift on Server.
import Elementary
import ElementaryPines
import ElementaryTailwind
struct ProductPage: HTMLDocument {
var title: String { "Featured product" }
var body: some HTML {
main(.maxWidth(.xxl), .marginX(.auto), .padding(.size(8))) {
pinesCard {
div(.padding(.size(7))) {
h2 { "Featured product" }
p { "A short description of the product." }
button { "Add to cart" }
.pinesButtonStyle(.solid, color: .blue)
}
}
}
}
}Generated HTML:
<main class="max-w-2xl mx-auto p-8">
<div class="rounded-lg overflow-hidden border border-neutral-200/60 bg-white text-neutral-700 shadow-xs">
<div class="p-7">
<h2>Featured product</h2>
<p>A short description of the product.</p>
<button class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium tracking-wide text-white transition-colors duration-200 rounded-md bg-blue-600 hover:bg-blue-700 focus:ring-2 focus:ring-offset-2 focus:ring-blue-700 focus:shadow-outline focus:outline-hidden">Add to cart</button>
</div>
</div>
</main>| ElementaryPines | ElementaryAlpine | ElementaryTailwind | Elementary |
|---|---|---|---|
| 0.1.xxx | — | — | 0.7.0 |
| 0.2.000 | 0.4.xxx | 0.3.000 | 0.8.0 |
| 0.2.100 (& Higher) | 0.4.xxx | 0.3.700 (& Higher) | 0.8.0 |
Add ElementaryPines to your Package.swift dependencies:
// swift-tools-version: 6.1
import PackageDescription
let package = Package(
name: "MyApp",
dependencies: [
.package(url: "https://github.com/amirsaam/elementary-pines.git", from: "0.1.100"),
],
targets: [
.target(
name: "App",
dependencies: [
.product(name: "ElementaryPines", package: "elementary-pines"),
]
),
]
)ElementaryPines depends on ElementaryAlpine and ElementaryTailwind (both typed attribute layers on top of Elementary). Swift Package Manager resolves these transitively — no need to declare them as direct dependencies.
This package requires Swift 6.1 with StrictConcurrency=complete and targets macOS v14, iOS v15, tvOS v17, watchOS v10.
// call `setupPines()` once in your document head — emits the [x-cloak] rule
// that hides elements before Alpine.js hydrates them, plus the theme tokens
// that color every component surface (light/dark via prefers-color-scheme)
import ElementaryPines
var head: some HTML {
meta(.charset(.utf8))
script(.src("https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4")) {}
setupPines()
setupAlpine(plugins: [.collapse, .focus])
}
// 11 colors via the shared `PinesColor` enum
button { "Save" }.pinesButtonStyle(.solid, color: .blue) // .amber, .blue, .gray, .green, .indigo, .neutral, .orange, .pink, .purple, .red, .yellow
span { "New" }.pinesBadgeStyle(.light, color: .amber)
// 35 icons × 5 sizes, Heroicons paths
pinesIcon(.check, size: .sm) // .xs, .sm, .md, .lg, .xl
pinesIcon(.warning, color: .red)
// extra attributes — pass Alpine directives or extra classes; classes merge
pinesIcon(.check, attributes: [.marginLeft(.size(2))])
pinesIcon(.check, attributes: [.x.show("isVisible")])// 3 button styles
button { "Save" }.pinesButtonStyle(.solid) // dark background, white text
button { "Cancel" }.pinesButtonStyle(.tonal, color: .red) // light tinted background
button { "Delete" }.pinesButtonStyle(.outline) // transparent, colored border// 4 card variants — same pattern as the original `pines/elements/card.html`
pinesCard {
div(.padding(.size(7))) { h2 { "Title" }; p { "Body" } }
}
pinesCard(.image, image: "photo.jpg") {
h3 { "Title" }
p { "Body" }
}
pinesCard(.stat) {
p(.fontSize(.xxxl), .fontWeight(.bold)) { "1,234" }
p(.fontSize(.sm), .textColor(PinesColor.neutral.shade(.base))) { "Total users" }
}// 4 alert variants — auto-inserts the matching icon, override with `.custom(...)`
pinesAlert(.info) {
h5 { "Heads up" }
p { "Something happened." }
}
pinesAlert(.danger, icon: .custom(path: "/icons/spinner.svg")) {
h5 { "Error" }
p { "Something went wrong." }
}// Alpine-animated progress bar; pass a value for a static bar
pinesProgress() // auto-animated 0→100, neutral, h-3
pinesProgress(45) // static 45%, neutral, h-3
pinesProgress(9, of: 20) // static 45%, neutral, h-3
pinesProgress(75, color: .green, size: .lg) // static 75%, green, h-4// fixed Alpine banner (top by default)
pinesBanner(
label: "New Feature",
message: "Click here to learn about our latest feature",
href: "#"
)
// black banner fixed to the bottom
pinesBanner(
label: "New Feature",
message: "Click here to learn about our latest feature",
href: "#",
position: .bottom
)// input — styled text input (type, placeholder, color, disabled, form attrs)
pinesInput(placeholder: "Name")
pinesInput(type: "email", color: .blue, placeholder: "Email")
pinesInput(type: "text", placeholder: "Search...", name: "q", value: "pine", id: "search")
pinesInput(placeholder: "Disabled", disabled: true)
pinesInput(placeholder: "Email", attributes: [.x.model("email")])// textarea — styled multi-line input (placeholder, color, rows, form attrs)
pinesTextarea(placeholder: "Type your message here.")
pinesTextarea(color: .blue, placeholder: "Bio")
pinesTextarea(placeholder: "Comment", name: "comment", rows: 4)
pinesTextarea(placeholder: "Disabled", disabled: true)
pinesTextarea(placeholder: "Message", attributes: [.x.model("message")])// select — custom Alpine-driven dropdown (items, placeholder, width)
// Replaces the native <select>. Requires Alpine.js (call setupAlpine() once
// in <head>). Items are JSON-encoded into the x-data state and the dropdown
// uses $refs/$watch/$id for open/close, keyboard nav, and click-away.
pinesSelect(items: [
.init(title: "Milk", value: "milk"),
.init(title: "Eggs", value: "eggs"),
.init(title: "Cheese", value: "cheese", disabled: true),
])
pinesSelect(items: fruits, placeholder: "Choose a fruit", width: .size(72))// checkbox — 3 overloads: .default (labelText), .card (content), .custom (labelClasses + content)
pinesCheckbox(labelText: "Remember me", name: "remember", id: "remember")
pinesCheckbox(labelText: "Terms", required: true, name: "terms", id: "terms")
pinesCheckbox(labelText: "Toggle", name: "opt", id: "opt") {
[.x.model("isChecked")]
}
// .card — hidden peer input with card-styled label wrapping content
pinesCheckbox(name: "lib", id: "lib") {
[.x.model("selected")]
} content: {
pinesIcon(.atSymbol, size: .xl, color: .blue)
div { "AlpineJS" }
}
// .custom — user provides labelClasses for peer-checked descendant targeting
pinesCheckbox(name: "x", id: "x", labelClasses: "peer-checked:[&_svg]:scale-100 [&_svg]:scale-0") {
[.x.model("toggle")]
} content: {
span(.class("custom-checkbox")) { /* svg */ }
span { "Custom" }
}// radio group — Alpine-driven, options JSON-encoded into x-data
pinesRadioGroup(options: [
.init(title: "Small", value: "sm"),
.init(title: "Medium", value: "md", description: "Recommended"),
.init(title: "Large", value: "lg"),
], name: "size")
// disabled radio group
pinesRadioGroup(options: options, name: "answer", disabled: true)// rating — interactive star/heart selection with Alpine.js
pinesRating()
pinesRating(icon: .heart, color: .pink)
pinesRating(emptyStyle: .filled)
pinesRating(compactReset: true)
pinesRating(icon: .heart, color: .pink, compactReset: true)// range slider — styled <input type="range"> with Tailwind pseudo-element classes
pinesRangeSlider(name: "volume", id: "volume")
pinesRangeSlider(color: .green, name: "price", id: "price", min: 0, max: 200, value: 50, step: "5")
pinesRangeSlider(name: "range", id: "range", disabled: true)// switch — Alpine-driven toggle with hidden checkbox
pinesSwitch(labelText: "Enable Feature", name: "feature", id: "feature")
pinesSwitch(labelText: "Wi-Fi", color: .green, name: "wifi", id: "wifi", checked: true)
pinesSwitch(labelText: "Small", size: .small, name: "sm", id: "sm")
pinesSwitch(labelText: "Airplane Mode", name: "airplane", id: "airplane", attributes: [.x.model("airplane")])// date picker — calendar dropdown with month/year navigation
pinesDatePicker()
pinesDatePicker(labelText: "Birthday", format: .mmDdYyyy)
pinesDatePicker(labelText: "Start Date", placeholder: "Pick a date", width: .size(72))
pinesDatePicker(disabled: true)// breadcrumb — manual items (bordered style; first crumb renders a home icon)
pinesBreadcrumb([
.link("Home", href: "/"),
.link("Docs", href: "/docs"),
.current("Installation"),
])
// text separators
pinesBreadcrumb(items, separator: .slash)
pinesBreadcrumb(items, separator: .arrow)
// no home icon
pinesBreadcrumb(items, homeIcon: .none)
// custom home icon image
pinesBreadcrumb(items, homeIcon: .custom(path: "/icons/home.svg"))
// breadcrumb — data-driven from a site map (Vapor, Hummingbird)
// The site map is declared once at app startup and shared across routes.
// The route handler passes the current request's path in.
// Vapor:
let siteMap: [PinesSiteMapEntry] = [
.init(path: "/", label: "Home"),
.init(path: "/docs", label: "Docs"),
.init(path: "/docs/getting-started", label: "Getting Started"),
]
app.get("docs", "**") { req async throws -> View in
let items = pinesBreadcrumbItems(for: req.url.path, in: siteMap)
return try await req.view.render("docs", ["breadcrumb": items])
}
// Hummingbird:
let siteMap: [PinesSiteMapEntry] = [
.init(path: "/", label: "Home"),
.init(path: "/docs", label: "Docs"),
]
router.get("/docs/{path=}") { request, context -> Response in
let items = pinesBreadcrumbItems(for: request.uri.path, in: siteMap)
return try context.render("docs", with: ["breadcrumb": items])
}// quote with optional avatar
pinesQuote(
quote: "This is a great quote.",
author: "Author Name",
role: "Role"
)
pinesQuote(
quote: "This is a great quote.",
author: "Author Name",
role: "Role",
avatar: "user.jpg"
)// accordion — collapsible title/body items; the activeAccordion state toggles each item
// The collapse animation uses x-collapse, so load the Collapse plugin:
// setupAlpine(plugins: [.collapse])
pinesAccordion(items: [
.init(title: "How do I install?", body: "Add ElementaryPines to your Package.swift."),
.init(title: "Does it run on Linux?", body: "macOS only — no Linux support tested."),
])// tabs — animated sliding marker, $id-scoped ids, one content panel per tab
pinesTabs(tabs: [
.init(title: "Overview") {
p { "A quick overview." }
},
.init(title: "Details") {
p { "More details." }
},
])// toast — teleported notification stack. Place pinesToast() once on the page,
// then dispatch toasts from your own Alpine code with toast(...):
// toast('Saved', { type: 'success', position: 'bottom-right' })
// toast('Heads up', { description: 'Something happened.' })
// Types: default/success/info/warning/danger; positions: the six screen edges.
// x-teleport is core Alpine — no plugin required.
pinesToast()// tooltip — wraps trigger content, shows text on hover
pinesTooltip(text: "More info") {
button { "Hover me" }
}
pinesTooltip(text: "Left side", position: .left, arrow: false) {
span { "Hover me" }
}
// dropdown — anchored menu with trigger button
pinesDropdown(color: .blue) {
img(.src("avatar.jpg"), .width(.size(8)), .height(.size(8)), .borderRadius(.full))
} items: {
[
pinesDropdownItem(.init(title: "Profile", icon: .user, shortcut: "⇧⌘P")),
pinesDropdownItem(.init(title: "Billing", icon: .billing, shortcut: "⌘B")),
pinesDropdownSeparator(),
pinesDropdownItem(.init(title: "Log out", icon: .lock, danger: true)),
]
}
// popover — anchored panel with arrow and auto flip
pinesPopover(position: .bottom, arrow: true) {
pinesIcon(.settings, size: .sm)
} content: {
p(.fontWeight(.medium)) { "Dimensions" }
p(.fontSize(.sm), .opacity(.value(60))) { "Set the dimensions for the layer." }
}
// menu bar — a bar of menu buttons, each opening a dropdown panel
pinesMenuBar(menus: [
.init(title: "File", items: [
.item(.init(title: "New Tab", shortcut: "⌘T", action: "newTab()")),
.separator,
.item(.init(title: "Print", shortcut: "⌘P")),
]),
.init(title: "Edit", items: [
.item(.init(title: "Undo", shortcut: "⌘Z")),
.item(.init(title: "Redo", shortcut: "⇧⌘Z", disabled: true)),
]),
])
// modal — a trigger that opens a centered dialog teleported to <body>
pinesModal {
p(.fontWeight(.medium)) { "Open settings" }
} content: {
h3(.fontSize(.lg), .fontWeight(.semibold)) { "Settings" }
p(.fontSize(.sm), .opacity(.value(60))) { "Configure your workspace." }
}// slide-over — a right-side drawer teleported to with a titled header pinesSlideOver(title: "Settings") { p(.fontWeight(.medium)) { "Open settings" } } content: { p(.fontSize(.sm), .opacity(.value(60))) { "Configure your workspace." } }
// command — a Spotlight-style palette with live search and arrow-key navigation
pinesCommand(items: [
.init(title: "Calendar", value: "calendar", icon: .calendar, category: "Suggestions"),
.init(title: "Profile", value: "profile", icon: .user, shortcut: "⌘P", category: "Settings"),
.init(title: "Delete", value: "delete", icon: .trash, category: "Danger", action: "remove()"),
])
The package ships 28 component functions. Each wraps the matching Pines UI element with type-safe parameters.
| Function | Variants | Notes |
|---|---|---|
setupPines(accent:bgLight:bgDark:) |
accent default .neutral; bgLight/bgDark default white/black |
Emits the [x-cloak] rule plus semantic color tokens (light/dark via prefers-color-scheme). Call once in <head>. |
PinesColor |
11 cases: amber, blue, gray, green, indigo, neutral, orange, pink, purple, red, yellow |
Shared enum used by every color-accepting component. |
.pinesButtonStyle(_:color:) |
3 styles × 11 colors = 33 variants | Modifier on button. |
.pinesBadgeStyle(_:color:) |
5 styles × 11 colors = 55 variants | Modifier on span. |
pinesCard(_:image:content:) |
4 variants: .basic, .image(image:), .horizontal(image:), .stat |
image: required for .image and .horizontal. |
pinesIcon(_:size:color:attributes:) |
35 kinds × 5 sizes = 175 variants; 11 colors; attributes: for extra classes / directives |
Heroicons 2.x paths. Classes passed via attributes: merge with the default size/color class. |
pinesAlert(_:icon:content:) |
2 overloads: pinesAlert { ... } (basic) and pinesAlert(.info, icon: .auto, ...) { ... } |
.auto inserts the matching icon; .none omits; .custom(path:) renders a user-provided SVG file via <img>. |
pinesProgress(_:of:color:size:) |
Default is the Alpine-animated bar; pass a value for a static bar. color: defaults to .neutral, size: to .md (h-3). |
Percentage clamped to 0–100. |
pinesQuote(quote:author:role:avatar:) |
Quote text, author name, role, optional avatar image URL. | avatar: omitted to render the quote without an avatar. |
pinesBreadcrumb(_:separator:homeIcon:) |
separator: .chevron (default), .slash, .arrow; homeIcon: .icon(PinesIconKind) (default .home), .custom(path:), .none |
See also pinesBreadcrumbItems(for:in:root:) for data-driven derivation. |
pinesBreadcrumbItems(for:in:root:) |
— | Derives items from a flat (path, label) site map and a current path. |
pinesBanner(label:message:href:icon:dismissible:position:) |
PinesBannerPosition .top (white) / .bottom (black). icon: defaults to .wand; pass nil to omit. |
Alpine-driven fixed banner with show/hide transitions and a dismiss button. |
pinesInput(type:color:placeholder:name:value:id:disabled:attributes:) |
String type (e.g. "text", "email", "password"); color overrides the 300/400 border + ring pair |
Tailwind-only — users add x-data/x-model on the call site for dynamic behavior. |
pinesTextarea(color:placeholder:name:id:rows:disabled:attributes:) |
String? placeholder; color overrides the 300/400 border + placeholder + ring pair |
Tailwind-only — no built-in auto-resize; users add x-data/x-model on the call site. |
pinesSelect(items:placeholder:width:) |
[PinesSelectItem] (Codable, Sendable, with title/value/disabled); typed TWTWidth |
Alpine-driven — emits full x-data state, x-init ($watch), 5 @keydown.* handlers, x-transition, x-cloak, x-for template. Requires setupAlpine() in <head>. |
pinesCheckbox |
3 overloads: .default (visible input + label), .card (peer-checked card), .custom (user-supplied labelClasses for peer-checked:[&_...] targeting) |
Use .default with labelText:; .card/.custom with content: trailing closure; all accept attributes: for Alpine directives on the <input>. |
pinesRadioGroup(options:name:disabled:) |
[PinesRadioGroupOption] with title/value/optional description; name groups radio inputs; disabled disables all |
Alpine-driven — emits full x-data state with x-for template loop; options JSON-encoded. Requires setupAlpine() in <head>. |
pinesRating(icon:color:emptyStyle:maxStars:value:disabled:compactReset:) |
2 icons (PinesRatingIcon) × 2 empty styles (PinesRatingEmptyStyle) × 11 colors; maxStars/value/disabled/compactReset |
Alpine-driven — hover preview, click-to-rate, reset button (compact inline or below). Requires setupAlpine() in <head>. |
pinesRangeSlider(color:name:id:min:max:value:step:disabled:) |
11 colors; configurable min/max/value/step/disabled |
Tailwind-only — [&::-webkit-slider-thumb], [&::-moz-range-track] etc. for custom thumb/track styling. No Alpine dependency. |
pinesSwitch(labelText:color:size:name:id:checked:disabled:attributes:) |
PinesSwitchSize: .default (h-6 w-10), .small (h-4 w-6); 11 colors; checked/disabled |
Alpine-driven — hidden checkbox, button toggle, label click. Requires setupAlpine() in <head>. |
pinesDatePicker(labelText:placeholder:format:width:disabled:) |
PinesDatePickerFormat: .monthDayYear (default), .mmDdYyyy, .ddMmYyyy, .yyyMmDd, .dayMonthShortYear; typed TWTWidth |
Alpine-driven — full calendar dropdown with month/year navigation, day-of-week headers, day grid, and x-transition. Requires setupAlpine() in <head>. |
pinesAccordion(items:) |
[PinesAccordionItem] (Sendable, Equatable, with title/body strings) |
Alpine-driven — the activeAccordion state toggles each item open/closed. The collapse animation uses x-collapse, so it requires setupAlpine(plugins: [.collapse]) in <head>. |
pinesDropdown(color:position:width:attributes:trigger:items:) |
PinesDropdownPosition: .belowCenter (default), .belowStart, .belowEnd, .aboveStart, .aboveCenter, .aboveEnd; PinesDropdownItem (Sendable, title/icon/shortcut/disabled/href/action/closeOnSelect/danger) + pinesDropdownSeparator(); color tints trigger/panel text and item hover |
Alpine-driven — styled trigger button toggles a panel anchored by position that closes on outside click; arrow-key navigation; items render as <a> (with href) or <div>, disabled items use data-disabled, danger items override the color with red. Requires setupAlpine() in <head>. |
pinesPopover(color:position:arrow:width:attributes:trigger:content:) |
PinesPopoverPosition: .bottom (default), .top; arrow: defaults to true; color tints trigger text/hover, focus ring, and panel text; typed TWTWidth |
Alpine-driven — trigger button toggles an anchored panel with arbitrary content; panel auto-flips top/bottom to fit the viewport; click-outside and Escape close; focus is trapped via x-trap, so it requires setupAlpine(plugins: [.focus]) in <head>. |
pinesMenuBar(color:attributes:menus:) |
color default .neutral; PinesMenuBarMenu (title + items); PinesMenuBarItem (title/shortcut/disabled/action); PinesMenuBarContent (.item/.separator) |
Alpine-driven — a bar of menu buttons; clicking opens a menu, hovering switches menus while open, clicking away closes, selecting an item runs its action and closes. Surfaces follow the theme; color tints the menu-button/item hover washes (.neutral keeps the theme bg-muted). |
pinesModal(color:attributes:trigger:content:) |
color default .neutral (tints trigger text/hover/focus ring) |
Alpine-driven — a trigger button opens a centered dialog teleported to <body> with backdrop and built-in close button; Escape, backdrop, or close dismisses; focus is trapped and scroll locked via x-trap.inert.noscroll, so it requires setupAlpine(plugins: [.focus]) in <head>. |
pinesSlideOver(size:title:color:attributes:trigger:content:) |
size .default (Pines max-w-md) / .half (max-w-1/2, half the viewport) / .full (max-w-full); title empty hides the header; color default .neutral (tints trigger) |
Alpine-driven — a right-side drawer teleported to <body> that slides in (translate-x-full → translate-x-0); backdrop, Escape, click.away, or the labeled Close button dismisses; titled header with built-in Close button when title is non-empty. No focus plugin required. |
pinesCommand(color:items:placeholder:attributes:) |
color default .neutral (tints the active-row highlight; .neutral keeps the theme bg-muted); PinesCommandItem (title/value/icon/shortcut/category/isDefault/disabled/action) |
Alpine-driven — a Spotlight-style palette panel: live title filtering, arrow-key navigation with auto-scroll, Enter selects the active row and runs its action, categorized headings, disabled rows are skipped. Place it inside your own overlay/modal. |
pinesTabs(tabs:) |
[PinesTab] with title + @ContentBuilder content closure |
Alpine-driven — animated sliding marker, $id-scoped ids, x-show content panels. Requires setupAlpine() in <head>. |
pinesToast(name:) |
name: scopes the stack to a container (optional) |
Alpine-driven — teleported toast stack (x-teleport to body; core Alpine, no plugin). Listens for toast-show window events; dispatch with toast('message', { type:, position:, description: }) from your own Alpine code. An unnamed stack shows every toast dispatched without a container; a named stack only shows toasts dispatched with { container: <name> } — so multiple stacks can coexist on one page. Types: default, success, info, warning, danger; positions: top-left, top-center, top-right, bottom-right, bottom-center, bottom-left. Requires setupAlpine() in <head>. |
pinesTooltip(text:position:arrow:content:) |
PinesTooltipPosition: .top (default), .left, .bottom, .right; arrow: defaults to true |
Alpine-driven — the @ContentBuilder content is the hover trigger; the tooltip shows on mouseenter/mouseleave. Requires setupAlpine() in <head>. |
Pines UI was designed for Alpine.js. The standard way to add Alpine directives to Elementary HTML is with the typed .x.* attribute helpers from ElementaryAlpine — they compile to the same HTML attributes and are verified by snapshot tests.
import Elementary
import ElementaryAlpine
import ElementaryPines
// x-text on a child of pinesAlert
pinesAlert(.info) {
h5(.x.text("title")) { "Title" }
p(.x.text("body")) { "Body" }
}
// x-on:click on a button before .pinesButtonStyle
button(.x.on("click", "count++")) { "Increment" }
.pinesButtonStyle(.solid, color: .blue)
// x-data on a wrapper
div(.x.data("{ progress: 0 }")) {
pinesProgress(0)
}ElementaryAlpine also provides setupAlpine(plugins:) which emits the CDN <script> tags for Alpine.js core + plugins. Every Pines component preserves Alpine directives passed as attributes — x-text, x-model, x-on:click, x-data, x-show, and the rest all survive Elementary's renderer.
The setupPines() function emits a <style> block. Call it once in the document <head> — every Pines component that supports animation emits x-cloak and breaks visibly without this rule in place.
var head: some HTML {
meta(.charset(.utf8))
setupPines()
script(.src("https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4")) {}
setupAlpine(plugins: [.collapse, .focus])
}Generated HTML:
<style>[x-cloak] { display: none !important; }</style>setupPines() also emits the semantic color tokens that theme every component surface, driven by prefers-color-scheme:
setupPines(accent: .neutral, bgLight: .white, bgDark: .neutral.shade(950))accent:seeds the brand tokens (--color-ring,--color-primary), default.neutral.bgLight:/bgDark:set--color-backgroundin light/dark mode, default white / near-black (neutral-950).foreground,border,muted,muted-foregroundderive from neutral pairs.
The emitted variables chain to Tailwind v4's own palette (var(--color-neutral-400), …), so overriding your Tailwind palette re-themes the components automatically, and the unlayered <style> wins over Tailwind's @layer theme defaults:
:root {
--color-background: var(--color-white);
--color-foreground: var(--color-neutral-950);
--color-border: var(--color-neutral-200);
--color-muted: var(--color-neutral-100);
--color-muted-foreground: var(--color-neutral-500);
--color-ring: var(--color-neutral-400);
--color-primary: var(--color-neutral-600);
--color-primary-foreground: var(--color-white);
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--color-neutral-950);
--color-foreground: var(--color-neutral-50);
--color-border: var(--color-neutral-800);
--color-muted: var(--color-neutral-900);
--color-muted-foreground: var(--color-neutral-400);
}
}bgLight/bgDark accept any TWColor — .white, .black, .neutral.shade(100), or .arbitrary("#fafafa"). The default dark surface is near-black (neutral-950) rather than pure black — softer on the eyes and leaves room for elevated surfaces; pass bgDark: .black for true OLED black. The accent still applies per-component via each component's color: parameter; the theme accent sets the global ring/primary tokens.
Plugin requirements: pinesAccordion animates with x-collapse, so load the Collapse plugin — setupAlpine(plugins: [.collapse]). pinesToast teleports with x-teleport, which ships in Alpine core and needs no plugin.
If you can't (or don't want to) use a CDN, self-host the compiled CSS output of your project — the same as you would for any other Tailwind project. The class strings in every component are the standard Tailwind utility classes, not custom CSS.
The PinesColor enum's 11 cases map to Tailwind's default color palette. Each case has a (solid, hover, ring) shade triple that follows the original Pines conventions:
| Shade range | Colors |
|---|---|
| 600 / 700 (cool) | blue, red, green, indigo, pink, purple |
| 500 / 600 (warm) | amber, orange, yellow |
| 700 / 800 (gray) | gray |
| 950 / 900 (neutral) | neutral |
.pinesButtonStyle(.solid, color: .neutral) renders bg-neutral-950 (the neutral dark end of the palette). .pinesBadgeStyle(.solid, color: .neutral) and .icon, color: .neutral render bg-black (per the original Pines badge source).
The full API is documented in source — every public type and function has doc comments with a code example and a "Generated HTML" snippet showing the rendered output. For example, see:
Sources/ElementaryPines/Pines.swift—setupPines()Sources/ElementaryPines/Button/Button+Style.swift—.pinesButtonStyleSources/ElementaryPines/Card/Card.swift—pinesCardSources/ElementaryPines/Alert/Alert+Variant.swift—pinesAlert
The full test suite (130 snapshot and integration tests, including Alpine directive smoke tests) lives in Tests/ElementaryPinesTests/.
The Pines UI library is a collection of pre-built Alpine.js and Tailwind CSS UI components — alerts, badges, banners, breadcrumbs, buttons, cards, modals, and more. We use it on the web and wanted the same components available in our server-side Swift applications. This package is that: the same components, rendered type-safely via Elementary with full snapshot test coverage.
28 component functions are implemented and tested:
setupPines,PinesColor,.pinesButtonStyle,.pinesBadgeStyle,pinesCard,pinesIcon,pinesAlert,pinesProgress,pinesQuote,pinesRating,pinesRangeSlider,pinesBreadcrumb,pinesBanner,pinesInput,pinesTextarea,pinesSelect,pinesCheckbox,pinesRadioGroup,pinesSwitch,pinesDatePicker,pinesAccordion,pinesTabs,pinesToast,pinesTooltip,pinesDropdown,pinesPopover,pinesMenuBar,pinesModal,pinesSlideOver,pinesCommand
Alpine directive compatibility is verified by a dedicated smoke suite covering x-text, x-model, x-on:click, x-data, x-show, and modifiers.
Why do some components return HTMLRaw(html.render()) instead of the element directly?
The package compiles with Swift 6.1 and StrictConcurrency=complete. Returning a deeply nested HTMLElement value (e.g. a div whose content is itself a span) directly from a some HTML function is miscompiled by the Swift 6.1 opaque-type inference — rendering produces corrupted output or a runtime SIGSEGV. Wrapping the already-rendered HTML string in HTMLRaw erases the concrete element type, sidestepping the bug.
It is not an optimization or a workaround for a design limitation — it is a compiler-bug workaround. Do not "simplify" HTMLRaw(html.render()) back to return html; the tests that cover these components will crash if you do.
Why are some Tailwind classes still written as raw strings?
Only classes with no typed ElementaryTailwind token are raw:
ring-offset-background,text-foreground— CSS-variable theme tokens.TWColor.arbitrary(...)would add brackets (ring-offset-[background]), changing the class name.shadow-outline,active-breadcrumb— custom component classes with no token case.
(divide-*, group/peer markers, and inset-1/2 style fractions gained typed tokens in elementary-tailwind 0.3.700.)
Why do some Alpine directives on SVGs use raw SVGAttribute(name: "x-show", ...)?
SVG elements don't conform to HTMLTrait.Attributes.Global, and ElementaryAlpine 0.4.000 does not expose x-show-style helpers for SVGAttribute. The raw attribute is the only supported path today.
- Phase 1 — Core UI primitives — shipped in 0.1.100: alert, badge, banner, breadcrumb, button, card, icon, progress, quote.
- Phase 2 — Form & control components — shipped in 0.2.000: checkbox, date picker, input, radio group, range slider, rating, select, switch, textarea.
- Phase 3 — Overlay & navigation (part 1) — shipped in 0.2.100: accordion, tabs, toast, tooltip.
- Phase 4 — Overlay & navigation (part 2) — in progress: dropdown, popover, menu-bar, modal, slide-over, command done; remaining: context-menu, hover-card, navigation-menu, image-gallery.
- Phase 5 — Data & media: table, pagination, menu bar, video.
- Phase 6 — Effects: marquee, retro grid, text animation, typing effect.
- Quick wins: textarea auto-resize, copy to clipboard.
- Example apps showing real integration in Vapor and Hummingbird.
PRs welcome.