The Best Way To Handle SVG Icons in FullStack Clojure Project

Table of Contents

  1. Introduction
  2. What are SVG Sprites
  3. Applying this to Clojure
    1. Usage
    2. Generating new icons
  4. Gotcha's
  5. Conclusion

Introduction

Clojure is awesome and ClojureScript is even more awesome. But when it comes to handling SVG icons in a Clojure project, it can be a bit tricky, especially since by default, the handling differs for both runtimes.

In this article, I will show you the best way to handle SVG icons in a full-stack Clojure project.

What are SVG Sprites?

SVG sprites are a collection of SVG icons that are combined into a single SVG file. This single SVG file is then included in the HTML served to the client.

Thus, a normal SVG icon file like this:

    <svg
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
    >
      <path
        d="M23.8769 23.5201C23.7959 23.666 23.6793 23.7872 23.5389 23.8715C23.3986 23.9557 23.2393 24 23.0772 24H0.922181C0.760219 23.9998 0.60115 23.9553 0.460949 23.871C0.320748 23.7867 0.204348 23.6655 0.123439 23.5196C0.0425297 23.3737 -4.15502e-05 23.2083 3.04306e-08 23.0399C4.16111e-05 22.8715 0.0426945 22.706 0.123676 22.5602C1.88108 19.401 4.58931 17.1357 7.74986 16.0619C6.18651 15.0942 4.97188 13.6196 4.2925 11.8646C3.61313 10.1096 3.50658 8.17127 3.9892 6.34723C4.47183 4.52318 5.51696 2.9143 6.96408 1.76765C8.4112 0.621007 10.1803 0 11.9997 0C13.8191 0 15.5882 0.621007 17.0353 1.76765C18.4825 2.9143 19.5276 4.52318 20.0102 6.34723C20.4928 8.17127 20.3863 10.1096 19.7069 11.8646C19.0275 13.6196 17.8129 15.0942 16.2496 16.0619C19.4101 17.1357 22.1183 19.401 23.8757 22.5602C23.9569 22.706 23.9998 22.8715 24 23.04C24.0002 23.2085 23.9577 23.374 23.8769 23.5201Z"
        fill="currentColor"
      />
    </svg>

Becomes a symbol in the main SVG file like this:

    <?xml version="1.0" encoding="UTF-8"?>
    <!-- This file is generated by bb build-icons -->
    <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0">
    <defs>
      <symbol fill="none" id="user" viewBox="0 0 24 24">
        <path
            d="M23.8769 23.5201C23.7959 23.666 23.6793 23.7872 23.5389 23.8715C23.3986 23.9557 23.2393 24 23.0772 24H0.922181C0.760219 23.9998 0.60115 23.9553 0.460949 23.871C0.320748 23.7867 0.204348 23.6655 0.123439 23.5196C0.0425297 23.3737 -4.15502e-05 23.2083 3.04306e-08 23.0399C4.16111e-05 22.8715 0.0426945 22.706 0.123676 22.5602C1.88108 19.401 4.58931 17.1357 7.74986 16.0619C6.18651 15.0942 4.97188 13.6196 4.2925 11.8646C3.61313 10.1096 3.50658 8.17127 3.9892 6.34723C4.47183 4.52318 5.51696 2.9143 6.96408 1.76765C8.4112 0.621007 10.1803 0 11.9997 0C13.8191 0 15.5882 0.621007 17.0353 1.76765C18.4825 2.9143 19.5276 4.52318 20.0102 6.34723C20.4928 8.17127 20.3863 10.1096 19.7069 11.8646C19.0275 13.6196 17.8129 15.0942 16.2496 16.0619C19.4101 17.1357 22.1183 19.401 23.8757 22.5602C23.9569 22.706 23.9998 22.8715 24 23.04C24.0002 23.2085 23.9577 23.374 23.8769 23.5201Z"
            fill="currentColor">
        </path>
      </symbol>
    </defs>
    </svg>

And can then be used in the HTML like this:

    <svg class="icon">
      <use href="/svg/sprite.svg#user"></use>
    </svg>

Ben Adam wrote a more in depth explanation in his blog The "best" way to manage icons in React.js

Applying this to Clojure

Let's take an example of a full-stack Clojure project:

  • The backend also serves static HTML routes (landing page, blog pages, about page, etc)

  • The frontend is a ClojureScript SPA using a React Wrapper (UIX for this post) that consumes the backend API.

    We don't want to have to manage the SVG icons in two different ways so we'll create a full-stack component - icon.cljc.

        (ns saas.common.ui.icon
          "Utility to use sprite SVG icons."
          (:require [uix.core :refer [defui $]]))

        (def href "/svg/sprite.svg")

        (def size-class-name
          {:font "w-[1em] h-[1em]"
           :xs "w-3 h-3"
           :sm "w-4 h-4"
           :md "w-5 h-5"
           :lg "w-6 h-6"
           :xl "w-7 h-7"})

        (def children-size-class-name
          {:font "gap-1.5"
           :xs "gap-1.5"
           :sm "gap-1.5"
           :md "gap-2"
           :lg "gap-2"
           :xl "gap-3"})

        (defui icon
          [{:keys [size class title] :as props
            :or {size :font}}]
          ($ :svg
            (merge
             (dissoc props :name :size :class :title)
             {:class (str (size-class-name size) "inline self-center" class)})
            (when title
              ($ :title title))
            ($ :use {:href (str href "#" (name (:name props)))})))

One other thing that can be done is to maintain a dictionary of available icons and throw an error if the icon is not found in the dictionary. The dictionary can be generated from the sprite file.

Usage

Given our sprite.svg file contains a symbol with the id user, we can use the icon component like this:

    (ns example
      (:require
       [saas.common.ui.icon :as i]
       [uix.core :refer [$ defui] :as uix]))

    ($ icon {:name :user
             :size :lg ;; or :md, :sm, :xs, :font}
             :class "text-red-500"})

If you server render UIX on the backend this will work correctly or if you use hiccup or selmer, you can reuse the same logic but just rewrite the icon component for that use case. The advantage is that you only have to manage the SVG icons in one place.

Generating new icons

I keep all my SVG icons in a folder called resources/icons and use the following babashka script to generate the sprite file:

    (ns build-icons
      (:require [clojure.string :as str]
                [babashka.process :refer [shell]]
                [babashka.pods :as pods]
                [babashka.fs :as fs]))

    (pods/load-pod 'retrogradeorbit/bootleg "0.1.9") ;; HTML parsing utils
    (require '[pod.retrogradeorbit.bootleg.utils :refer [html->hickory hickory->html]])

    (def cwd (fs/cwd))
    (def input-dir (fs/path cwd "resources" "icons")) ;; Directory containing the SVG icons
    (def output-dir (fs/path cwd "resources" "public" "svg")) ;; Directory to write the sprite file
    (def sprite-file-path (fs/path output-dir "sprite.svg")) ;; Path to the sprite file

    (defn current-sprite
      []
      (try
        (slurp (str sprite-file-path))
        (catch Exception _e
          (println "Error reading sprite file")
          nil)))

    (defn ensure-dir
      "Ensures that the directory exists. If it does not exist, it is created.
       Returns true if the directory was created, false if it already existed."
      [dir]
      (if (fs/exists? dir)
        (if (fs/directory? dir)
          false  ; Directory already exists
          (throw (ex-info (str "Path exists but is not a directory: " dir)
                          {:dir dir})))
        (do
          (fs/create-dirs dir)
          true)))

    (defn ensure-file
      "Ensures that the file exists in the specified directory.
       If the directory doesn't exist, it is created.
       If the file doesn't exist, an empty file is created.
       Returns true if the file was created, false if it already existed."
      [input-dir filename]
      (let [dir-path (fs/path input-dir)
            file-path (fs/path dir-path filename)]
        (ensure-dir dir-path)
        (if (fs/exists? file-path)
          (if (fs/regular-file? file-path)
            false  ; File already exists
            (throw (ex-info (str "Path exists but is not a regular file: " file-path)
                            {:file file-path})))
          (do
            (fs/create-file file-path)
            true))))

    ;; Ensure the output directory and file exist
    (ensure-file output-dir "sprite.svg")

    (fs/glob input-dir "**.svg")

    (def files
      (->> (fs/glob input-dir "**.svg")
           (mapv #(fs/relativize input-dir %))
           (sort)))

    (defn write-if-changed!
      "Writes content to a file if it's different from the current content.
       Returns true if the file was written, false otherwise."
      [filepath new-content]
      (let [current-content (if (fs/exists? filepath)
                              (slurp filepath)
                              "")]
        (if (= current-content new-content)
          false
          (do
            (spit filepath new-content)
            (shell {:out :string}
                   "./node_modules/.bin/prettier" "--write" (str filepath) "--ignore-unknown")
            true))))

    (defn icon-name [file]
      (str/replace file #"\.svg$" ""))

    (defn trim-last-newline [s]
      (str/replace-first s #"\n$" ""))

    (defn process-svg [input-dir file]
      (let [input (trim-last-newline (slurp (fs/file input-dir file)))
            svg (html->hickory input)
            attrs (-> (:attrs svg)
                      (dissoc :xmlns :xmlns:xlink :version :width :height :viewbox)
                      (assoc :id (icon-name file))
                      (assoc :viewBox (-> svg :attrs :viewbox)))]
        (when-not svg
          (throw (ex-info "No SVG element found" {:file file})))
        (-> svg
            (assoc :tag :symbol)
            (assoc :attrs attrs)
            hickory->html
            str/trim)))

    (defn generate-svg-sprite [{:keys [files input-dir]}]
      (let [symbols (mapv #(process-svg input-dir %) files)]
        (str/join "\n"
                  ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
                   "<!-- This file is generated by bb build-icons -->"
                   "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"0\" height=\"0\">"
                   "<defs>"
                   (str/join "\n" symbols)
                   "</defs>"
                   "</svg>"
                   ""])))

    (defn generate-icons-file!
      "Builds svg icons into a sprite"
      []
      (let [icon-names (map icon-name files)
            cs (current-sprite)
            sprite-up-to-date? (every? #(str/includes? (or cs "") %) icon-names)]
        (if sprite-up-to-date?
          (println "Icons are up to date")
          (do
            (println "Generating sprite for icons " (str input-dir))
            (let [sprite-output (generate-svg-sprite {:files files
                                                      :input-dir input-dir})]
              (write-if-changed! (str sprite-file-path) sprite-output))
            (for [icon icon-names]
              (println "✅ " icon))))))

bb.edn

    {:pods {retrogradeorbit/bootleg {:version "0.1.9"}}
     :min-bb-version "0.4.0"
     :tasks {build-icons {:doc "Builds svg icons into a single sprite"
                          :task build-icons/generate-icons-file!}}}

Icons directory:

    repo/resources/icons:
        arrow-right.svg
        bell.svg
        check.svg
        database.svg
        ellipsis.svg
        eye-off.svg
        eye.svg
        file-search.svg
        folder-plus.svg
        folders.svg
        log-in.svg
    ... other icons

And now, to add a new icon to your project you just add the svg file to your icons directory and run bb run build-icons.

Gotcha's

  • Make sure your HTML preloads the final sprite.svg
    <head>
      <!-- Preload svg sprite as a resource to avoid render blocking -->
      <link rel="preload" href="/svg/sprite.svg" as="image">
    </head>
  • Make sure all your icons contain a viewBox attribute. In the file sprite.svg file, all your symbols should contain a viewBox if you want the icons to adapt to the dimensions of the container.

    Read further on why this is necessary: Complete guide to SVG Sprites

Conclusion

SVG Sprites are a great way to manage icons in a full stack clojure project without hassle.

This is the main way icons are handled in ShipClojure and it is very easy to hanlde.