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

See docs/how-it-works.md for details and limits.

Documentation

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

License

MIT