When working with logs or console output in Elixir, you may encounter ANSI escape codes used to style text (e.g., colors or bold). If you're rendering that output in HTML, you'll need to convert those ANSI codes to appropriate CSS classes. Here's a small Elixir helper module that does just that—mapping ANSI codes to TailwindCSS classes.

The problem

ANSI escape codes like \e[31m (red text) or \e[1m (bold) are common in CLI output but aren't usable directly in a browser. Instead, we want to transform:

"This is \e[31mred\e[0m and \e[1mbold\e[0m."

...into valid HTML like:

This is <span class="text-red-400">red</span> and <span class="font-bold">bold</span>.

The solution

Here's a lightweight Elixir module that parses ANSI codes and converts them to TailwindCSS utility classes:

defmodule LogColorsHelper do
  @ansi_to_tailwind %{
    "1" => "font-bold",
    "2" => "text-gray-500",
    "31" => "text-red-400",
    "32" => "text-green-400",
    "33" => "text-yellow-300",
    "34" => "text-blue-400",
    "35" => "text-purple-400",
    "36" => "text-cyan-400",
    "90" => "text-gray-500",
    "97" => "text-gray-100"
  }

  def ansi_to_tailwind(log) do
    regex = ~r/\e\[(\d+(?:;\d+)*)m/
    do_ansi_to_tailwind(log, regex, "", false)
  end

  defp do_ansi_to_tailwind("", _regex, acc, span_open?) do
    if span_open?, do: acc <> "</span>", else: acc
  end

  defp do_ansi_to_tailwind(log, regex, acc, span_open?) do
    case Regex.run(regex, log, return: :index) do
      nil ->
        acc <> (if span_open?, do: log <> "</span>", else: log)

      [{match_start, match_len} | _] ->
        {before, rest} = String.split_at(log, match_start)
        {match, rest_after} = String.split_at(rest, match_len)

        codes =
          match
          |> String.replace(~r/\e\[|\m/, "")
          |> String.split(";")

        is_reset = Enum.member?(codes, "0")

        classes =
          unless is_reset do
            codes
            |> Enum.map(&Map.get(@ansi_to_tailwind, &1))
            |> Enum.reject(&is_nil/1)
          else
            []
          end

        span_tag =
          if is_reset or classes == [] do
            ""
          else
            ~s(<span class="#{Enum.join(classes, " ")}">)
          end

        new_acc =
          acc <>
            before <>
            (if span_open?, do: "</span>", else: "") <>
            span_tag

        do_ansi_to_tailwind(rest_after, regex, new_acc, not is_reset and classes != [])
    end
  end
end

The core logic

The work is done recursively in do_ansi_to_tailwind/4.

defp do_ansi_to_tailwind("", _regex, acc, span_open?) do
  if span_open?, do: acc <> "</span>", else: acc
end

When the input string is empty, we close any open span and return the accumulated result.

defp do_ansi_to_tailwind(log, regex, acc, span_open?) do
  case Regex.run(regex, log, return: :index) do
    nil ->
      acc <> (if span_open?, do: log <> "</span>", else: log)
  • If no ANSI codes are left, append the rest of the string to the output. If a <span> was open, close it.
    [{match_start, match_len} | _] ->
      {before, rest} = String.split_at(log, match_start)
      {match, rest_after} = String.split_at(rest, match_len)
  • Otherwise, we locate the next ANSI sequence using Regex.run/3.

  • We split the string into:

    • before: plain text before the ANSI code,
    • match: the matched ANSI code,
    • rest_after: remaining string after the code.
      codes =
        match
        |> String.replace(~r/\e\[|\m/, "")
        |> String.split(";")
  • We extract the actual SGR codes from the ANSI sequence by removing \e[ and m.
  • \e[1;31m becomes ["1", "31"].
      is_reset = Enum.member?(codes, "0")
  • ANSI code 0 means "reset all styles", so we check for it explicitly.
      classes =
        unless is_reset do
          codes
          |> Enum.map(&Map.get(@ansi_to_tailwind, &1))
          |> Enum.reject(&is_nil/1)
        else
          []
        end
  • If not a reset, we convert the codes to Tailwind classes.
  • Unknown codes are ignored (Map.get/2 will return nil).
      span_tag =
        if is_reset or classes == [] do
          ""
        else
          ~s(<span class="#{Enum.join(classes, " ")}">)
        end
  • We generate a new <span> tag only if it’s not a reset and there are valid classes.
      new_acc =
        acc <>
          before <>
          (if span_open?, do: "</span>", else: "") <>
          span_tag
  • Append the plain text, close any previously open <span>, then insert the new <span> if needed.
      do_ansi_to_tailwind(rest_after, regex, new_acc, not is_reset and classes != [])
  • Recurse into the rest of the string.
  • span_open? is updated to reflect whether we just opened a new span.

Example

iex> LogColorsHelper.ansi_to_tailwind("This is \e[31mred\e[0m and \e[1mbold\e[0m.")
"This is <span class=\"text-red-400\">red</span> and <span class=\"font-bold\">bold</span>."

Conclusion

This approach makes it easy to render styled logs in a browser using TailwindCSS. You can extend the @ansi_to_tailwind map to support more ANSI codes or customize the styling for your design system.

inspiration