Skip to content

About

An unofficial Tailwind CSS integration and tooling for Neovim

Resources

Stars

3 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

Important

This is a fork of luckasRanarison/tailwind-tools.nvim. It targets Neovim 0.12, drops the nvim-lspconfig dependency, and fixes a pile of things that fell over in monorepos. See what's different. This plugin is a community project and is NOT officially supported by Tailwind Labs.

tailwind-tools.nvim

An unofficial Tailwind CSS integration and tooling for Neovim written in Lua and JavaScript, leveraging the built-in LSP client, Treesitter, and the NodeJS plugin host. It is inspired by the official Visual Studio Code extension.

preview

Contents

What's different in this fork

Upstream assumes one Tailwind project per Neovim session. That's fine in a single-app repo and comes apart in a monorepo, which is what most of the work here is about. The rest is catching up to Neovim 0.12's native LSP APIs and fixing bugs found on the way.

Monorepos

Rooting happens per buffer. root_markers is a tiered list, so vim.fs.root picks the nearest Tailwind config for each file and only falls back to package.json when there's no config anywhere above it. Nested packages get their own server: Neovim reuses a client only when the requested workspace folder matches one it already has by exact URI, never by "is this a parent directory", so a package nested inside another project never gets handed the parent's client.

Everything layered on top is scoped the same way.

Sort and color requests go to the tailwindcss client attached to that buffer. There's no "grab the first client in the session" fallback, which is exactly how a request ends up at a server rooted somewhere else entirely. When more than one client is attached to a buffer, the one with the longest matching root wins.

Project state is keyed by client and config path, so sibling projects don't overwrite each other's config path or Tailwind version. Server notifications only affect the client that sent them — a clearColors from one project can't wipe another's swatches. And LspDetach is a per-buffer event, so it drops only that buffer's association; a client's records go away once its last buffer detaches.

If you'd rather run a single server for the whole repo, override the markers:

require("tailwind-tools").setup({
  server = {
    root_markers = { ".git" },
  },
})

That's supported. One client then serves every project it finds under that root, and because state is keyed by project rather than by client, :checkhealth tailwind-tools still reports each project's own config and version separately. The tradeoff is a single server process for the entire tree: memory grows with the repo, and a config error in one package affects everything.

Neovim 0.12 and the native LSP API

Server setup goes through vim.lsp.config / vim.lsp.enable, so nvim-lspconfig is no longer a dependency. Color hints are rendered by the native vim.lsp.document_color added in 0.12, which replaced roughly a hundred lines of hand-rolled request, debounce and extmark code. Conceal mode keeps a custom renderer because the native one can't do cursor-line-only rendering.

Smaller things in the same vein: vim.uv over vim.loop, vim.lsp.get_clients over vim.lsp.get_active_clients, the positional vim.validate signature, plugin state in a Lua module instead of vim.g (which was paying msgpack serialization on hot paths), and one persistent vim.uv timer instead of vim.defer_fn for debouncing.

LSP calls use the client:request() method form; the client.request(...) dot form has been deprecated since 0.11. In-flight textDocument/documentColor requests are cancelled before a new one goes out, so typing or moving around doesn't stack up redundant round-trips.

Server notifications

The tailwindcss-language-server sends a handful of custom notifications that upstream ignores. This fork handles them, each scoped to the client that sent it. It also advertises the experimental.tailwind.projectDetails capability — without that the server never sends @/tailwindCSS/projectDetails at all, so the handler for it was dead code until this was fixed.

Notification What this fork does
@/tailwindCSS/projectInitialized Fires the first color request once the project is actually ready, instead of colors not showing up until your first edit. Only that client's buffers get painted
@/tailwindCSS/projectReset Clears the notifying client's project records and its buffers' color extmarks when the project reloads, e.g. after a config error
@/tailwindCSS/projectsDestroyed Same as projectReset; covers full server disposal and restart
@/tailwindCSS/clearColors Clears stale swatches for the notifying client's buffers only, then re-requests
@/tailwindCSS/projectReloaded Re-fetches colors for that client's buffers after a config or CSS change, since theme colors may have moved. Native document_color doesn't re-request on reload by itself
@/tailwindCSS/projectDetails Records config path and Tailwind version per project, shown in :checkhealth tailwind-tools, which lists every active project
@/tailwindCSS/warn Surfaces server warnings through vim.notify rather than dropping them

Utilities and previews in a workspace

:Telescope tailwind utilities reads your project's real Tailwind install through the NodeJS host, and two things were broken there.

It looked for node_modules/tailwindcss only in the directory holding the config. In a workspace with dependencies hoisted to the repo root — the normal npm, pnpm and yarn layout — it found nothing and the picker just came up empty. Resolution now goes through Node's own resolver anchored at the project directory, so hoisting, symlinks and local overrides all behave.

The picker also fetched the utility list when it opened but re-derived the project when rendering a preview, from whatever buffer happened to be current at that moment. Once the Telescope window had focus that could be a different project, or none at all. The project is now pinned when the picker opens and handed to the preview.

Failures used to be silent. The Node side answers with a structured result now, so you get told what actually happened: Tailwind isn't installed, the config wouldn't load, the config is ESM and can't be required, or the project is v4 (utilities are still v3-only). A config that throws is caught rather than taking the remote host down with it.

Smart increment

<C-a> / <C-x> on Tailwind units had a few real bugs:

  • Custom units in your config were ignored. The module grabbed the unit list at load time, which happens before your options are merged, so it always used the defaults.
  • On a line with more than one class attribute it took the first one regardless of where the cursor was. The cursor's column is now checked against the range it's actually inside.
  • Values were matched as Lua patterns instead of literal text, so 0.5 also matched 10.5 and 0.50 — . being a wildcard. Matching is literal now, with boundary checks.
  • A count re-resolved everything on each iteration. It resolves once and applies the whole count in a single edit, which also avoids reusing byte offsets that shift when a replacement changes width, like p-9 to p-12.

Performance

Lua-pattern class extraction rebuilt the buffer's line offset table for every match, calling nvim_buf_get_offset once per line each time. On a 200-line buffer with 100 matches that was 20,000 API calls. It's zero now — offsets come from the lines already read, and lookup is a binary search. Extracted ranges are byte-identical to before.

Motions recomputed and re-sorted every class range once per count. They compute once and walk the list instead.

Tests

The test bootstrap still called nvim-treesitter.configs and TSInstallSync, both removed in nvim-treesitter's main rewrite, so parsers were never installed and the php, twig and htmldjango specs failed for that reason alone. The harness now uses the current install API with a pinned nvim-treesitter revision and fails loudly if installation doesn't work. Those three specs pass.

There's also a monorepo fixture with a project nested inside another, covering client routing, per-project isolation and detach behaviour, plus Node-level tests for module resolution (cd rplugin/node/tailwind-tools && npm test).

Known limitations

  • The utilities picker and class expansion are Tailwind v3 only. v4 projects get a clear message rather than silence, but the feature doesn't work there yet.
  • TailwindColorEnable / TailwindColorDisable toggle Neovim's document colors session-wide, so they also affect other language servers that provide colors.
  • In conceal mode, colors are rendered against the current window's cursor line, so a buffer displayed in a background window can be painted against the wrong line.
  • @/tailwindCSS/projectReloaded is only sent when the server runs in test mode, so that handler rarely fires in practice.
  • on_attach can run twice for a single attach, which doubles the initial color request.

Bug fixes

  • Fixed the highlight cache guard in utils.lua — nvim_get_hl returns a dict, not an array, so the cache was a no-op
  • Fixed the tresitter typo in classes.lua
  • Replaced pairs() with ipairs() on array tables throughout

Features

The plugin works with all languages inheriting from html, css and tsx treesitter grammars (php, astro, vue, svelte, ...). Lua patterns can also be used as a fallback.

It currently provides the following features:

Note

Language services like autocompletion, diagnostics and hover are already provided by tailwindcss-language-server.

Prerequisites

Installation

Using lazy.nvim:

-- tailwind-tools.lua
return {
  "Eingin/tailwind-tools.nvim",
  name = "tailwind-tools",
  build = ":UpdateRemotePlugins",
  dependencies = {
    "nvim-treesitter/nvim-treesitter",
    "nvim-telescope/telescope.nvim", -- optional
  },
  opts = {} -- your configuration
}

If you are using other package managers, you need register the remote plugin by running the :UpdateRemotePlugins command, then call setup to enable the lua plugin:

require("tailwind-tools").setup({
  -- your configuration
})

Configuration

By default, the plugin automatically configures tailwindcss-language-server using Neovim's native LSP API (vim.lsp.config). Make sure you do not set up the server elsewhere.

Here is the default configuration:

---@type TailwindTools.Option
{
  server = {
    override = true, -- setup the server from the plugin if true
    settings = { -- shortcut for `settings.tailwindCSS`
      -- experimental = {
      --   classRegex = { "tw\\('([^']*)'\\)" }
      -- },
      -- includeLanguages = {
      --   elixir = "phoenix-heex",
      --   heex = "phoenix-heex",
      -- },
    },
    on_attach = function(client, bufnr) end, -- callback executed when the language server gets attached to a buffer
    root_markers = { -- tiered; see lua/tailwind-tools/lsp.lua for the full default
      { "tailwind.config.js", "tailwind.config.ts" --[[ , ... ]] }, -- nearest config wins
      "package.json", -- fallback
    },
  },
  document_color = {
    enabled = true, -- can be toggled by commands
    kind = "inline", -- "inline" | "foreground" | "background"
    inline_symbol = "󰝤 ", -- only used in inline mode
    debounce = 200, -- in milliseconds, only applied in insert mode
  },
  conceal = {
    enabled = false, -- can be toggled by commands
    min_length = nil, -- only conceal classes exceeding the provided length
    symbol = "󱏿", -- only a single character is allowed
    highlight = { -- extmark highlight options, see :h 'highlight'
      fg = "#38BDF8",
    },
  },
  keymaps = {
    smart_increment = { -- increment tailwindcss units using <C-a> and <C-x>
      enabled = true,
      units = {  -- see lua/tailwind/units.lua to see all the defaults
        {
          prefix = "border",
          values = { "2", "4", "6", "8" },
        },
        -- ...
      }
    }
  },
  cmp = {
    highlight = "foreground", -- color preview style, "foreground" | "background"
  },
  telescope = {
    utilities = {
      callback = function(name, class) end, -- callback used when selecting an utility class in telescope
    },
  },
  -- see the extension section to learn more
  extension = {
    queries = {}, -- a list of filetypes having custom `class` queries
    patterns = { -- a map of filetypes to Lua pattern lists
      -- rust = { "class=[\"']([^\"']+)[\"']" },
      -- javascript = { "clsx%(([^)]+)%)" },
    },
  },
}

Commands

Available commands:

  • TailwindConcealEnable: enables conceal for all buffers.
  • TailwindConcealDisable: disables conceal.
  • TailwindConcealToggle: toggles conceal.
  • TailwindColorEnable: enables color hints for all buffers.
  • TailwindColorDisable: disables color hints.
  • TailwindColorToggle: toggles color hints.
  • TailwindSort(Sync): sorts all classes in the current buffer.
  • TailwindSortSelection(Sync): sorts selected classes in visual mode.
  • TailwindNextClass: moves the cursor to the nearest next class node.
  • TailwindPrevClass: moves the cursor to the nearest previous class node.

Note

In normal mode, TailwindNextClass and TailwindPrevClass can be used with a count to jump through multiple classes at once.

Utilities

nvim-cmp

Utility function for highlighting colors in nvim-cmp using lspkind.nvim:

-- nvim-cmp.lua
return {
  "hrsh7th/nvim-cmp",
  dependencies = {
    "tailwind-tools",
    "onsails/lspkind-nvim",
    -- ...
  },
  opts = function()
    return {
      -- ...
      formatting = {
        format = require("lspkind").cmp_format({
          before = require("tailwind-tools.cmp").lspkind_format
        }),
      },
    }
  end,
},

Tip

You can extend it by calling the function and get the returned vim_item, see the nvim-cmp wiki to learn more.

telescope.nvim

The plugins registers by default a telescope extension that you can call using :Telescope tailwind <subcommand>

Available subcommands:

  • classes: Lists all the classes in the current file and allows to jump to the selected location.

  • utilities: Lists all utility classes available in the current project with a custom callback.

Extension

The plugin already supports many languages, but requests for additional language support and PRs are welcome. You can also extend the language support in your configuration by using Treesitter queries or Lua patterns (or both).

Treesitter queries

Treesitter queries are recommended because they can precisely capture the class values at the AST level, but they can be harder to write. If you are not familiar with Treesitter queries, check out the documentation from Neovim or Treesitter.

You can define custom queries for a filetype by adding the filetype to the queries list, like this:

{
  extension = {
    queries = { "myfiletype" },
  }
}

The plugin will search for a class.scm file (classexpr) associated with that filetype in your runtimepath. You can use your Neovim configuration folder to store queries in the following way:

~/.config/nvim
.
├── init.lua
├── lua
│   └── ...
└── queries
    └── myfiletype
        └── class.scm

The class.scm file should contain a query used to extract the class values for a given filetype. The class value should be captured using @tailwind, as shown in the following example:

; queries/myfiletype/class.scm
(attribute
  (attribute_name) @_attribute_name
  (#eq? @_attribute_name "class")
  (quoted_attribute_value
    (attribute_value) @tailwind))

Note that quantified captures (using + or ?) cannot be captured using @tailwind. Instead, you must capture the parent node using @tailwind.inner.

(arguments
  (_)+) @tailwind.inner

You can also define node offsets by using the #set! directive and assign the start or end variables to some offset values (defaults to 0).

((postcss_statement
   (at_keyword) @_keyword
   (#eq? @_keyword "@apply")
   (plain_value)+) @tailwind.inner
 (#set! @tailwind.inner "start" 1))

Lua patterns

Lua patterns are easier to write, but they have some limitations. Unlike Treesitter queries, Lua patterns cannot capture nested structures, they are limited to basic pattern matching.

You can define custom patterns by attaching a list of patterns to filetypes. Each pattern should have exactly one capture group representing the class value, as shown below:

{
  extension = {
    patterns = {
      javascript = { "clsx%(([^)]+)%)" },
    },
  }
}

Tip

Lua patterns can be combined with Treesitter queries. You can use both for a single filetype to get the combined results.

Related projects

Here are some related projects:

Contributing

Read the documentation carefully before submitting any issue.

Feature and pull requests are welcome.

About

An unofficial Tailwind CSS integration and tooling for Neovim

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages