HeexLint
Write design system rules that agents can verify, for Phoenix.
HeexLint is an agent-first linter for Tailwind classes in HEEx templates, with
rule parity with @shadcn/lint.
You define what's allowed. When code breaks a rule, the error explains what's wrong and suggests a fix based on your components, variants and theme:
lib/my_app_web/live/settings_live.ex:42:23 error "p-4" is not allowed on <.button>:
<.button> owns its spacing. Use a size (sm, lg), or margin here or gap on the parent
for space around it. Add a size in lib/my_app_web/components/core_components.ex only if
the design explicitly calls for one. [no_restyle]
Works with any Tailwind v4 project. It reads your function components, their
attr declarations, and your theme's @theme tokens. No rewrite required.
Quickstart
Give your coding agent this prompt:
Read https://github.com/adriancarayol/heex_lint/blob/main/SETUP.md
and set up heex_lint in this project.
Or add it to your dev and test dependencies yourself:
def deps do
[
{:heex_lint, "~> 0.3", only: [:dev, :test], runtime: false}
]
end
Run it:
mix heex_lint
With no configuration, the recommended rules run. Add it to
your precommit alias, and tell your agents in AGENTS.md:
precommit: ["compile --warnings-as-errors", "format", "heex_lint", "test"]
After making changes, run `mix precommit` and fix all errors.
Rules
| Rule | What it catches |
|---|---|
no_restyle |
Restyling a design-system component with class. |
no_raw_colors |
Raw palette colors, undeclared tokens, and raw SVG colors. |
no_arbitrary_values |
Arbitrary values such as p-[13px]. |
no_inline_styles |
Inline styles and <style> elements. |
no_unknown_classes |
Classes your Tailwind cannot generate, such as rounded-huge. |
require_static_classes |
Component classes the linter cannot read, such as "bg-#{@color}". |
no_unknown_variables |
Classes reading CSS variables the theme never defines. HeexLint addition. |
Each rule's module documents its examples and options. The shared options,
class categories and placeholders are in docs/rules.md. For a
style value a library computes, such as @floating_styles, see
allow_dynamic_identifiers.
You decide what can change
A Button that allows margin and width, but controls its own padding:
no_restyle: {:error, allow: ["layout"], contracts: [
[pattern: "^button$", allow: ["w-full", "mt-*", "mb-*"]]
]}
<%!-- Allowed: a size, and the page controls placement and full width. --%>
<.button size="lg" class="mt-4 md:w-full">Save</.button>
<%!-- Error: padding and shape belong to the button. --%>
<.button class="p-4 hover:rounded-full">Save</.button>
Give each part of a component its own rules. Let card titles change typography, but keep their font family and weight; let card content change spacing, but keep its typography:
no_restyle: {:error, allow: ["layout"], contracts: [
[pattern: "^card_title$", allow: ["layout", "typography"], deny: ["font-*"]],
[pattern: "^card_content$", allow: ["layout", "spacing"]]
]}
Opening up spacing doesn't have to mean allowing arbitrary values. Combine
rules: no_arbitrary_values still keeps p-[13px] off the scale.
How it reads your project
- Components. A design-system component is a function component in one of
your component modules: by default, modules under a
components/directory, where Phoenix putsCoreComponents, plus the modulesuiandcomponent_importsadd.<.button>resolves through the module's imports, including whatuse MyAppWeb, :htmlbrings in, and<Layouts.app>through its aliases. - Components you don't own. Modules imported from
deps/(SaladUI, PetalComponents, Doggo...) are read too, so their components resolve with their attrs. They join the design system whenuiorcomponent_importsnames them:component_imports: ["^SaladUI\\."]. - Variants.
attr :variant, values: ~w(primary ghost)gives the variants a finding suggests;attr :size, values: ...the sizes a spacing finding offers. - Wrappers. A component that forwards its
class(class={["w-full", @class]}) or its global attributes ({@rest}withattr :rest, :global) to a design-system component gets that component's contract and suggestions. Each class prop follows its own path, so atitle_classhanded tocard_titleis checked againstcard_title. - Theme. The stylesheet that imports Tailwind, found under the project (or
theme). Colors are the--color-*tokens in@theme(and a utility's own namespace, such as--background-color-*). The scales@themedeclares join the class grammar over Tailwind's defaults, so--radius-cardmakesrounded-carda shape class and--radius-*: initialmakesrounded-lgunrecognized. An@utilityis classified by the properties its body sets. A design system installed as a package counts oncetheme_importsnames it. - Values. Strings, lists,
if/case/cond,&&/||,~w(...), interpolation and<>.@namefollowsassign(assigns, :name, ...)in the rendering function; a function component's receivedclassis its own input, allowed as-is, with its authored default checked. Up to 64 bindings along a chain of body variables, module attributes, same-module helpers such asdefp button_variant("primary"), do: "...", map lookups, and merge functions (cn,Tails.classes,TwMerge.merge...); past that a class counts as unreadable, while astylehas no such limit. - Tailwind.
no_unknown_classesasks your installed Tailwind v4: through Node whentailwindcssis in reach, innode_modulesbeside the theme or beside a stylesheet it imports, or through the standalonetailwindbinary Phoenix installs in_build/. Without either it uses the class grammar, which checks less, with no warning. A Tailwind past the waitHEEX_LINT_TAILWIND_TIMEOUTsets is stopped and that stylesheet uses the grammar too, with a warning. Other rules ask only for what the grammar cannot answer, such as a font size from an@configor@plugin.
See docs/how-it-works.md for details and limits.
Documentation
- Rules: shared options, contracts, placeholders and categories.
- Configuring your design system: variants, contracts, messages, shared policies.
- How it works: components, themes, values, and what it cannot see.
- Adding linting to an existing project.
- Troubleshooting.
- API reference.
- Parity with @shadcn/lint.
Configuration
Create .heex_lint.exs in your project root. Every key is optional; without
rules, this recommended set applies:
[
inputs: ["lib/**/*.{ex,exs,heex}"],
settings: [
# theme: "assets/css/app.css",
# ui: "MyAppWeb.CoreComponents",
# note: "See DESIGN.md for design rules and approved exceptions."
],
rules: [
no_restyle: {:error, allow: ["layout"]},
no_raw_colors: :error,
no_arbitrary_values: {:error, allow: ["layout"]},
no_inline_styles: :error,
require_static_classes: :error,
no_unknown_classes: :warning,
no_unknown_variables: :error
],
# Components own their appearance.
overrides: [
[
files: ["**/components/**"],
rules: [no_restyle: :off, no_arbitrary_values: :off, require_static_classes: :off]
]
]
]
A rule is :error, :warning, :off or {severity, options}. Overrides
apply to matching files in order; one that sets only a severity keeps the
options set before it.
Settings
| Setting | What it does |
|---|---|
theme |
The Tailwind stylesheet. Discovered when not set. |
ui |
Module prefixes added to the design system: "MyAppWeb.UI" matches it and MyAppWeb.UI.*. |
component_imports |
Regexes on module names that are also the design system. |
ignore_imports |
Regexes on module names that never are. Takes precedence. |
theme_imports |
Regexes on @import specifiers whose stylesheets count as your theme, for a design system installed as a package. |
merge_functions |
Functions whose arguments contain classes, such as "classes". |
variant_functions |
Functions whose config holds classes, under base, variants and the like. |
note |
Appended to every rule's message. |
tailwind_bin |
The standalone Tailwind binary, when it is not in _build/. |
A rule's own ui, component_imports, ignore_imports, merge_functions
or variant_functions option wins over the shared setting. theme_imports is a
setting only.
Your own words
Every rule accepts message, with placeholders from the finding:
no_raw_colors: {:error, message: ~s(Use a theme color for "{{className}}". See {{file}}.)}
no_restyle also takes one message per category, and contracts can bring
their own:
no_restyle: {:error, allow: ["layout"], message: %{
spacing: "Use a {{component}} size: {{sizes|none defined}}.",
default: "Use a {{component}} variant: {{variants|none defined}}."
}}
See placeholders.
Suggestions and fixes
Findings carry suggestions: the nearest theme tokens, an exact scale step
(p-[13px] → p-3.25), the variable shorthand (bg-[var(--x)] →
bg-(--x)), or a spelling correction. --format json includes them, with
exact: true on the ones that generate the same CSS. mix heex_lint --fix
applies only those, rewriting just the class inside its literal; a nearest
color or a spelling correction is a choice left to you or your agent. In
GitHub Actions, --format github turns findings into annotations on the pull
request.
Exceptions
Document an intentional exception next to the code:
<%!-- heex-lint-disable-next-line no_raw_colors -- Partner brand color, approved by design. --%>
<span class="bg-amber-400">Sponsor</span>
heex-lint-disable-line and heex-lint-disable-file work too, in HEEx or
Elixir comments. grep -rn "heex-lint-disable" finds them all.
Adopting it
Start with warnings, fix the common patterns, then promote rules to errors.
mix heex_lint --max-warnings 287 fails CI when the count grows; add
--quiet to print only errors while the existing warnings are triaged. See
docs/adoption.md.
Parity with @shadcn/lint
The six rules, the policy engine (allow, deny, contracts, categories,
groups, patterns, entry validation), messages and placeholders, theme reading
and the Tailwind oracle follow @shadcn/lint 0.2.4 and the changes merged after
it, up to commit 9231259, down to its message text. The class grammar is
cn 0.4.0's, with the scales your theme
declares applied.
A parity check runs @shadcn/lint's own rules and HeexLint over equivalent React and Phoenix projects, asking the same Tailwind: across 13 themes and up to 552 classes on elements, components and wrappers, with and without contracts and custom messages, their 71,377 findings are identical, with no accepted differences. What changes for Phoenix is how components, variants, wrappers and values are found. See docs/parity.md, which also lists where the two differ beyond what the check reaches.
Acknowledgements
- @shadcn/lint and cn (MIT), whose rules, messages and grammar this ports.
- The HEEx tokenizer is vendored from Phoenix LiveView (MIT).
- The default palette and scales come from Tailwind CSS (MIT).
License
MIT