alexbruf/elm-daisyui/1.0.0

elm-daisyui

A typed, closed composition layer over daisyUI 5 for Elm 0.19.1.

You do not write HTML or class strings. You build a value of type Daisy.Tree.Page and hand it to Daisy.Render.page. daisyUI's CSS is used unchanged; Daisy.Render is the only module in the package that emits a class attribute.

The four guarantees

The compiler and the test suite together guarantee:

  1. No contradictory modifiers on one element. btn-sm and btn-lg, or two colours, cannot both be set: each exclusive class group of a component is one Maybe field of a generated type, so there is nowhere to put the second value.
  2. No invalid nesting. A card inside a card, a section inside a block, a card-body outside a Card — none of them is a value the constructors can build.
  3. No visual overlap. Modal, drawer, toast, dropdown and tooltip are not nodes you place. Overlays live only in Page.overlays and are rendered by Daisy.Render into one fixed layer in a fixed order (drawer, modal, toast); dropdown and tooltip are fields on a leaf's config.
  4. A page content budget. Sections has constructors for one to five sections and no others, and Page.cta is a mandatory single field, so "six sections" and "zero or two primary CTAs" are type errors rather than runtime checks.

There is no Raw Html escape hatch, and there never will be. Markup the tree cannot express is recorded in fixtures/rejected.md with a reason.

Install

elm install alexbruf/elm-daisyui

The package depends on elm/html, elm/json, elm/svg, terezka/elm-charts, justinmimbs/date and alexbruf/elm-cally. You still need daisyUI's CSS in your build — see CSS setup.

The model

A page is four levels deep and each level may only hold the level below it. Page carries a Shell (Plain, or Dashboard with a sidebar menu and a navbar), a Sections value holding one to five Sections (Hero, Navbar, Footer, Grid, Stack), a mandatory cta — the page's only primary button, which the renderer places according to the shell — a list of Overlays, and a Theme. A Section holds Blocks (Card, Stat, Table, Form, Chart, Alert, Prose, Menu, Steps, Timeline and the rest of daisyUI's block-level components); a Block holds Leafs (Button, Badge, Input, Select, Toggle, Calendar, Text, Heading, …), which hold only data. Overlay (Modal, Drawer, Toast) exists only as an element of Page.overlays, never as a child of anything else, and a Leaf.Button cannot take the Primary colour because Page.cta owns it. Where a daisyUI component has part classes the tree takes a record of parts (CardParts, NavbarParts, CollapseParts, …) rather than free children, so a part can neither appear outside its component nor twice.

A complete example

Browser.sandbox, one section, one card, one badge, one CTA:

module Main exposing (main)

import Browser
import Daisy.Render
import Daisy.Schema.Badge as Badge
import Daisy.Tree exposing (..)
import Html exposing (Html)


type alias Model =
    { clicks : Int }


type Msg
    = Clicked


main : Program () Model Msg
main =
    Browser.sandbox { init = { clicks = 0 }, update = update, view = view }


update : Msg -> Model -> Model
update Clicked model =
    { model | clicks = model.clicks + 1 }


view : Model -> Html Msg
view model =
    let
        card =
            { emptyCardParts
                | title = Just "Clicks so far"
                , body = [ CardLeaf (Badge { defaultBadgeConfig | color = Just Badge.Secondary } (String.fromInt model.clicks)) ]
            }
    in
    Daisy.Render.page
        (Page
            { shell = Plain
            , sections =
                Sections1
                    (Stack defaultStackConfig
                        [ Prose [ Heading H1 "Clicker" ]
                        , Card defaultCardConfig card
                        ]
                    )
            , cta = cta "Click me" Clicked
            , overlays = []
            , theme = Dark
            , dock = Nothing
            , fab = Nothing
            }
        )

Daisy.Render.page returns Html msg, so it fits Browser.sandbox, Browser.element and Browser.document alike. It writes data-theme on the page root itself, which is what switches the daisyUI theme.

Configs

Every component with class groups has an XConfig record and a defaultXConfig value. There is no String class field anywhere:

  • one Maybe <Group> field per exclusive group (color, style, size, direction, placement, variant), whose type comes from the generated module for that component — Daisy.Schema.Button.Color, Daisy.Schema.Card.Size, Daisy.Schema.Input.Color, …
  • modifiers : List <Component>.Modifier (and behaviors where the component has a behavior group) for the groups that are genuinely sets.

So a config is built by updating the default:

import Daisy.Schema.Button as Button

saveButton : Leaf Msg
saveButton =
    Button
        { defaultButtonConfig
            | color = Just Secondary
            , size = Just Button.Sm
            , modifiers = [ Button.Wide ]
            , onClick = Just Save
        }
        "Save"

size and modifiers take the generated Daisy.Schema.Button types, but color takes Daisy.Tree.ButtonColor, which is the same list of colours without Primary: the primary button is Page.cta and nothing else can become one.

The Daisy.Schema.* modules are generated from daisyUI's own docs frontmatter by tools/gen-schema.js; each exposes its types, a <group>ToClass function, component, componentClasses, parts and an all<Group> list. Daisy.Schema aggregates them (allClasses, exclusiveGroups, parts, componentClasses) and is what the test suite checks the renderer against.

Per-item state lives on the item, not the container: menu-active is a Bool on a MenuItem, tab-active on a Tab, step-* colours on a Step.

Charts

Daisy.Chart is a closed configuration over terezka/elm-charts. No elm-charts type appears in its API, so chart code cannot reach the underlying library:

import Daisy.Chart as Chart

revenueCard : Block msg
revenueCard =
    Card defaultCardConfig
        { emptyCardParts
            | title = Just "Net revenue vs. operating cost"
            , body =
                [ CardChart Chart.Line
                    { xLabels = [ "Jan", "Feb", "Mar" ]
                    , series =
                        [ { name = "Revenue", color = Chart.Primary, points = [ 182, 201, 226 ] }
                        , { name = "Cost", color = Chart.Neutral, points = [ 120, 128, 131 ] }
                        ]
                    }
                ]
        }

ChartConfig is Line | Bar | StackedBar | Donut | Area. Series colours are the daisyUI semantic palette (Primary … Neutral) and are emitted as var(--color-primary) and friends, so charts re-colour with the theme without the renderer knowing any concrete colour. Chart height is fixed per block size token; there is no per-call override.

Calendar

Leaf.Calendar renders a date picker through alexbruf/elm-cally, a pure-Elm port of the Cally web component: same markup and part attributes, light DOM, no ports and no custom elements. elm-cally's own Config is built inside Daisy.Render and never exposed, the same rule Daisy.Chart follows for elm-charts.

The picker has state, so it lives in your model and takes three functions from the package:

init : ( Model, Cmd Msg )
init =
    ( { calendar = Daisy.Render.initCalendarRange calendarConfig Nothing }, Cmd.none )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        CalendarMsg sub ->
            Daisy.Render.updateCalendar calendarConfig sub model.calendar
                |> Tuple.mapFirst (\state -> { model | calendar = state })

        RangePicked value ->
            ( { model | calendar = Tree.setCalendarValue value model.calendar }, Cmd.none )

initCalendarDate, initCalendarRange and initCalendarMulti build the three kinds of state; CalendarConfig carries today, toMsg (for CalendarMsg) and onChange (for the picked value). updateCalendar returns a Cmd because the roving tabindex needs Browser.Dom.focus.

The picker needs two stylesheets that are not part of daisyUI: elm-cally's own base stylesheet, and daisyUI's .cally block with ::part(x) rewritten to [part~="x"] (elm-cally has no shadow DOM, so ::part never matches). bun tools/gen-cally-css.js writes both from Cally.Css.stylesheet in the installed elm-cally package; the generated pair is committed here as demo/cally-base.css and demo/cally-daisy.css and can be copied as-is. Import them after the daisyUI plugin line so its layers are already registered.

CSS setup

Tailwind 4 plus the daisyUI plugin, and then one thing that is specific to Elm.

Elm compilers used with Vite (vite-plugin-elm) compile in memory and write no .elm.js file, so Tailwind's scanner has no build artifact to read class names out of. Point @source at the Elm source files that hold the class-name literals instead — Tailwind 4's scanner is extension-agnostic and finds btn-primary inside an Elm string literal as readily as inside HTML. Two files (plus one directory) are enough, because they are the only places a daisyUI class string exists: Daisy/Render.elm and the generated Daisy/Schema*.

demo/app.css in this repository does exactly that, with paths relative to the checkout:

@import "tailwindcss";
@plugin "daisyui" {
  themes: all;
}

@source "../src/Daisy/Render.elm";
@source "../src/Daisy/Schema";
@source "../src/Daisy/Schema.elm";
@source "./src";

As a consumer the package is not in your repository, it is in the Elm package cache, so point at it there — @source takes a path relative to the CSS file, and does not expand ~, so use a real path:

@source "/home/you/.elm/0.19.1/packages/alexbruf/elm-daisyui/1.0.0/src/Daisy/Render.elm";
@source "/home/you/.elm/0.19.1/packages/alexbruf/elm-daisyui/1.0.0/src/Daisy/Schema";
@source "/home/you/.elm/0.19.1/packages/alexbruf/elm-daisyui/1.0.0/src/Daisy/Schema.elm";
@source "./src";

elm-stuff/ holds compiled artifacts, not the package's sources, so it is not a substitute. If a machine-independent path matters more than the extra files, copy src/Daisy/Render.elm and src/Daisy/Schema* into a vendored directory in your repository and @source that — the check that matters either way is that every class in Daisy.Schema.allClasses survives into your built CSS (bun tools/css-coverage.js is that check here).

If you use Leaf.Calendar, add the two calendar stylesheets after the plugin line:

@import "./cally-base.css";
@import "./cally-daisy.css";

Demos

Three demo applications, each a single Page value, built only through the tree. They share one router (demo/src/Main.elm) and are live at https://alexbruf.github.io/elm-daisyui/. The dashboard sidebars also link to the documentation site at /docs/, which tools/build-docs-site.js generates from the repository's markdown as part of the demo build.

Dashboard shell, stats and a chart card — Shell.Dashboard (a daisyUI drawer that is open from lg: up), four Stat tiles in a four-column Grid, a Chart Line inside a Card, a Table with badges and a per-row action, and a Toast overlay:

statsSection : Section msg
statsSection =
    Grid { columns = Tree.Cols4 }
        [ statBlock "Revenue (MTD)" "$248,930" "18.2% vs last month"
        , statBlock "Orders" "3,412" "402 awaiting fulfilment"
        , statBlock "Active users" "12,847" "1,204 new this week"
        , statBlock "Refund rate" "1.8%" "0.4 points below target"
        ]

Theme switcher — all 35 daisyUI themes as one Leaf.ThemeSelect in the navbar. ThemeAsDropdown is the presentation that stays one control wide; the others render a sibling input.theme-controller per theme:

themeSwitcher : Config msg -> Leaf msg
themeSwitcher config =
    ThemeSelect
        { themes = Tree.allThemes
        , current = config.theme
        , presentation = ThemeAsDropdown
        , onSelect = Just config.onTheme
        }

Form with a validator and a modal — Shell.Plain, Form blocks of Fieldsets carrying toggles, selects and inputs (one a real type="email" required field, so daisyUI's validator-hint shows), the page's single CTA, and a Modal confirm overlay:

emailField : Config msg -> Field msg
emailField config =
    validatedField "Contact email"
        "Enter an address we can actually reach, e.g. [email protected]"
        (Input
            { defaultInput
                | color = Just SInput.Info
                , value = config.contactEmail
                , inputType = InputEmail
                , required = True
                , onInput = Just config.onContactEmail
            }
        )

The third demo, Analytics, is the chart-heavy one: Bar, Donut and Area charts, a stat row and a two-month Leaf.Calendar range picker.

What is deliberately inexpressible

Each of these is a consequence of one of the four guarantees, not an oversight. The full list, one line per rejected daisyUI docs example, is in fixtures/rejected.md; the tree-level reasoning is in docs/tree-decisions.md.

Not expressibleWhy
Two placement classes on one dropdown (a corner, e.g. dropdown-top dropdown-end)daisyUI declares placement as pick-at-most-one, so the config holds one value
A card or an arbitrary popover as dropdown-contenta dropdown's content is a MenuSpec, which keeps a leaf from containing blocks
A btn-primary anywhere but the page CTA (drawer buttons, FAB main actions, modal confirms)exactly one primary CTA per page; Leaf.Button's colour type omits Primary
A card or collapse carrying join-itemJoinItem is a closed list of button, input, select and text, so a block cannot be joined
A modal, drawer or toast inside a Blockoverlays exist only in Page.overlays, which is what fixes the stacking order
A card-body or any other part class on a standalone elementparts are fields of their component's parts record
Tailwind variant prefixes such as is-drawer-open:they are variants, not classes an element can carry
Per-call spacing overrides between sections or blocksspacing comes from one constant table, Daisy.Render.tokens

Testing and CI

Everything the type checker does not guarantee has a test. bash tools/ci.sh runs the whole thing in a fixed order: gen-schema diff → elm make → package docs → elm-review → elm-test → render audit → should-not-compile → gen-cally-css → demo build → css-coverage → Playwright. Any failure stops it.

TierWhatCountAsserts
Aelm-test (tests/)931class coverage against schema.json, group exclusivity under fuzzing, part/parent pairing, render purity and determinism, and a hand-written tree per daisyUI docs example (fixtures/corpus)
Belm-review rule tests (review/tests/)24positive and negative cases for NoClassOutsideRender, NoHtmlInDemo, NoRawSchemaStrings
Bshould-not-compile (tools/should-not-compile/)2120 fixtures that must fail elm make with the expected error, plus one control that must compile
Bcss-coverage—every class in Daisy.Schema.allClasses is present in the built demo CSS
Bpackage docs—elm make --docs and elm-format --validate src, the two things elm publish checks
CPlaywright (e2e/)293 across 6 projectsoverlap, overflow, stacking layers, responsive behaviour, 35-theme screenshot baselines, WCAG contrast, axe a11y, keyboard and focus trapping, interaction

The six Playwright projects are viewports 375/768/1440 × themes light/dark; themes.spec.ts sweeps all 35 daisyUI themes inside one of them. Baselines are committed in e2e/snapshots.

Further reading

Licence

BSD-3-Clause. See LICENSE.