amirsaam/elementary-pines
Type-safe Pines UI components for Elementary — the Alpine.js + Tailwind UI library, rendered directly from Swift on Server.
Compatibility
| ElementaryPines | ElementaryAlpine | ElementaryTailwind | Elementary | | --------------------- | ---------------- | ------------------ | ---------- | | 0.1.xxx | — | — | 0.7.0 | | 0.2.xxx | 0.4.xxx | 0.3.xxx (v4.3.3) | 0.8.0 |
Use it
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.
Quick tour
// call `setupPines()` once in your document head — emits the [x-cloak] rule
// that hides elements before Alpine.js hydrates them
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"
)Components
The package ships 18 component functions. Each wraps the matching Pines UI element with type-safe parameters.
| Function | Variants | Notes | | ------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | setupPines() | — | Emits the <style>[x-cloak]… rule. 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>. |
Alpine integration
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.
Setup
The setupPines() function emits a single <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>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.
Color conventions
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).
Documentation
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 (122 snapshot and integration tests, including Alpine directive smoke tests) lives in Tests/ElementaryPinesTests/.
Why this exists
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.
Current state (v0.1.100+)
18 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
Alpine directive compatibility is verified by a dedicated smoke suite covering x-text, x-model, x-on:click, x-data, x-show, and modifiers.
FAQ
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.peer,group— marker classes;TWVariantonly definesgroup-/peer-variants, not the marker itself.
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.
Future directions
- v0.1.200 form components are done (Textarea auto-resize, Copy to Clipboard remaining).
- v0.1.300 overlay and navigation: modal, slide-over, popover, dropdown, tabs, accordion, toast, tooltip, command, context-menu, hover-card, navigation-menu, image-gallery.
- v0.1.400+ data and media: table, pagination, menu bar, video.
- v0.1.500+ effects: marquee, retro grid, text animation, typing effect.
- Example apps showing real integration in Vapor and Hummingbird.
PRs welcome.
License
Package Metadata
Repository: amirsaam/elementary-pines
Default branch: main
README: README.md