Skip to Main Content

Displaying Icons in Slint An interactive guide to Lucide Slint

Introduction
Emotion TL;DR TL;DR

This post explores some of Lucide Slint's internals.
If you only need the tutorial or a quick reference, skip to Tutorial.

When I started building Lucide Slint, my goal was to make icons easier to use in Slint.
I initially assumed the implementation would be simple: package Lucide's SVG files and export them as image properties.

That is exactly what early Lucide Slint releases (0.1.x) did:

export global Icons {
    /// AArrowDownIcon
    out property <image> AArrowDownIcon: @image-url("icons/a-arrow-down.svg");
}

Using an icon was straightforward:

Image {
    source: Icons.AArrowDownIcon;
    colorize: #212121;
}

This approach is simple, but it comes with a few trade-offs.

Image vs. Path

There are two common ways to render SVG artwork in Slint:
  • Image: Load a complete SVG file into an Image element. Early Lucide Slint releases used this approach.

    Image {
        source: @image-url("icon.svg");
    }
  • Path: Pass SVG path commands to the Path element. Reconstructing a complete SVG this way takes more work and supports only the features exposed by Path, but it gives you much more control over the result. Current Lucide Slint releases use this approach.

    Path {
        commands: "M 17 12 L 15 12 L 13 17 L 11 7 L 9 12 L 7 12";
        stroke: #fff;
    }

At their intended size, the two approaches can look almost identical.
However, image-based icons can become blurry when they are placed at fractional physical-pixel positions, as can happen inside buttons.

Two buttons with icons. The image-based icon on the left is blurred, while the path-based icon on the right remains sharp. Image-based icon on the left, and the path-based icon on the right

Likewise, transform-scale can make an SVG-backed Image even blurrier because current renderers apply the transform to an already rasterized image instead of rasterizing the SVG again at the transformed size.

Button {
    transform-scale: 2;
    icon: @image-url("./a-arrow-down.svg");
    colorize-icon: true;
}

See these two issues for more details:

Button image (SVG) sometimes blurry, depending on position transform-scale should make SVG images scale up smoothly as vectors, not bitmaps

Rendering quality was not the only reason for the change. More importantly, Path exposes properties such as stroke, stroke-width, fill, stroke-line-cap, and stroke-line-join. An Image can be recolored with colorize, but it cannot independently restyle the stroke and fill encoded in the source SVG. Path therefore lets Lucide Slint offer an API much closer to the official Lucide packages.

Performance

At the time of writing (Slint 1.17.1), both Image and Path have performance trade-offs.

SVG-backed Image

  • On native platforms, Slint parses SVGs with usvg and rasterizes them on the CPU with resvg and tiny-skia. The renderer then draws the resulting bitmap.

  • The software renderer rasterizes an SVG while drawing it. If the image intersects a dirty region, that work may be repeated on subsequent redraws.

  • Image caching behavior differs between renderers, so the cost also depends on the renderer and how the image is used.

Path

  • FemtoVG and the software renderer rebuild renderer-specific path data while drawing; Skia caches its converted path per item.

  • Every Lucide Slint path binds commands dynamically to path.command, so Slint parses every icon path at runtime.

  • The software renderer allocates a mask for the full Path item area, and Path is not supported in line-by-line rendering mode.

In my software-renderer benchmark, Path was about 2.4 μs faster per icon than an SVG-backed Image. This result is workload- and renderer-specific.

I ultimately chose Path for Lucide Slint, but Image remains a valid choice for displaying icons.

How Lucide Slint Works

Early Lucide Slint releases used Image, so Lucide Slint could not expose Lucide's stroke and fill controls. Once I learned more about Path, I started working on a migration.

The release generator does the following work ahead of time:

Read the Lucide and Lucide Lab SVGs

The generator reads the SVG files provided by the installed lucide-static and @lucide/lab packages.

Normalize every shape into path data

The generator parses and serializes each SVG with usvg. This converts primitives such as <circle> and <rect> into path data that Slint's Path element can consume.

Merge paths

The generator then runs SVGO's convertPathData and mergePaths optimizations. Compatible adjacent paths are merged, often leaving a single path per icon. Paths with different paint attributes remain separate. This is important for preserving the behavior of shapes that are filled in the source SVG. Fewer source paths mean fewer Slint Path elements at runtime.

Generate fit metadata for every path

Take CircleCheck as an example. It contains two shapes:

<svg viewBox="0 0 24 24">
    <circle cx="12" cy="12" r="10" />
    <path d="M 9 12 L 11 14 L 15 10" />
</svg>

Now consider a direct translation into two Slint Path elements:

export component Icon inherits Rectangle {
    Path {
        commands: Circle;
    }

    Path {
        commands: Check;
    }
}

The result looks like this:

Before Slint 1.16, every Path with a non-zero width or height used a lyon_algorithms::fit::FitStyle::Min transform, equivalent to ImageFit::Contain. This fitted each path independently and broke the original relationship between the shapes.

// Slint 1.15 internal/core/graphics/path.rs

pub fn fit(&mut self, width: f32, height: f32, viewbox: Option<lyon_path::math::Box2D>) {
    if width > 0. || height > 0. {
        let viewbox =
            viewbox.unwrap_or_else(|| lyon_algorithms::aabb::bounding_box(self.iter()));
        self.transform = lyon_algorithms::fit::fit_box(
            &viewbox,
            &lyon_path::math::Box2D::from_size(lyon_path::math::Size::new(width, height)),
            lyon_algorithms::fit::FitStyle::Min,
        );
    }
}

Lucide Slint currently works around this behavior by calculating viewbox-* values for every generated path. These values counteract the transform, producing a result close to what would happen if no transform were applied.

// Lucide icon size
size = 24

// Calculate the path's offset relative to the origin
x-offset = bounds.left / size
y-offset = bounds.top / size

// Adjust the viewbox to preserve the path's size and position
viewbox-x = -x-offset
viewbox-y = -y-offset
viewbox-width = size + x-offset
viewbox-height = size + y-offset

Using the CircleCheck bounds gives the following two sets of values (rounded):

Circle left = 2, top = 2 2 / 24 = 0.0833 x/y = -0.0833
w/h = 24.0833
Check left = 9, top = 10 9 / 24 = 0.3750
10 / 24 = 0.4167
x/y = -0.3750 / -0.4167
w/h = 24.3750 / 24.4167

bounds divide by 24 negate for x/y, add for width/height

Those calculations produce the values stored in the generated Slint library:

export component Icon inherits Rectangle {
    Path {
        viewbox-x: -0.0833;
        viewbox-y: -0.0833;
        viewbox-width: 24.0833;
        viewbox-height: 24.0833;

        commands: Circle;
    }

    Path {
        viewbox-x: -0.3750;
        viewbox-y: -0.4167;
        viewbox-width: 24.3750;
        viewbox-height: 24.4167;

        commands: Check;
    }
}

The intended composition looks like this:

Slint 1.16 introduced a cleaner option through this pull request:

Path: Add support for configuring the transform from viewbox to Path
// Slint 1.16 internal/core/graphics/path.rs

pub fn fit(
    &mut self,
    width: f32,
    height: f32,
    viewbox: Option<lyon_path::math::Box2D>,
    style: ImageFit,
) {
    if width > 0. || height > 0. {
        let fit_style = match style {
            ImageFit::Contain => lyon_algorithms::fit::FitStyle::Min,
            ImageFit::Cover => lyon_algorithms::fit::FitStyle::Max,
            ImageFit::Fill => lyon_algorithms::fit::FitStyle::Stretch,
            ImageFit::Preserve => return,
        };
        let viewbox =
            viewbox.unwrap_or_else(|| lyon_algorithms::aabb::bounding_box(self.iter()));
        self.transform = lyon_algorithms::fit::fit_box(
            &viewbox,
            &lyon_path::math::Box2D::from_size(lyon_path::math::Size::new(width, height)),
            fit_style,
        );
    }
}

Setting ImageFit::Preserve skips view-box fitting and keeps the path at its original coordinate scale:

Path {
    fit: preserve;
    commands: Circle;
}

However, Slint still offsets the geometry by half the stroke width, so fit: preserve is not yet a drop-in replacement for the metadata workaround. Lucide Slint also continues to support Slint 1.15, where the fit property is unavailable.

Generate the Slint library

Finally, the icon data and the reusable IconDisplay component are written to .slint files. Those files are included in the crate published on crates.io.

This is how Lucide Slint exposes styling options similar to those in the official Lucide packages.

Tutorial

Now let's see how to use Lucide Slint. The repository's README provides a quick reference, while this tutorial offers more explanation and interactive examples.

Installation

At its core, installing Lucide Slint means making the generated Slint files available to your project. Every release includes lucide.slint and lucide-lab.slint.

If you do not use CMake or Cargo, download these files from the latest GitHub release and add them to your project manually.

Rust

Rust has first-class support. Lucide Slint is published on crates.io and can be added with Cargo.
Because the library must be registered with slint-build, install it as a build dependency:

cargo add lucide-slint --build

Then register lucide and lucide-lab in build.rs. Choose whether you want to include Lucide Lab:

use std::{collections::HashMap, path::PathBuf};

fn main() {
    let library = HashMap::from([
        ("lucide".to_string(), PathBuf::from(lucide_slint::lib())),
        (
            "lucide-lab".to_string(),
            PathBuf::from(lucide_slint::lib_lab()),
        ),
    ]);
    let config = slint_build::CompilerConfiguration::new().with_library_paths(library);

    slint_build::compile_with_config("ui/main.slint", config).expect("Slint build failed");
}

The lucide_slint::lib() and lucide_slint::lib_lab() functions return the paths to the generated .slint files. Passing these paths to the compiler makes the @lucide and @lucide-lab imports available in Slint.

C++

For C++ projects, Lucide Slint release includes a CMake package named cmake.tar.gz starting from 1.32.0.

Add this to CMakeLists.txt to download Lucide Slint with FetchContent:

FetchContent_Declare(
    LucideSlint
    URL https://github.com/cnlancehu/lucide-slint/releases/latest/download/cmake.tar.gz
)

FetchContent_MakeAvailable(LucideSlint)

Then add LIBRARY_PATHS to your slint_target_sources call. Choose whether you want to include Lucide Lab:

slint_target_sources(my_application ui/app-window.slint
    LIBRARY_PATHS ${LucideSlint_LIBRARY_PATHS}
)

Import Lucide Slint

In a .slint file, import the library like this:

import { IconDisplay, IconSet, Icon } from "@lucide";
import { LabIconSet } from "@lucide-lab";

If you downloaded the files manually, import from lucide.slint and lucide-lab.slint using their actual paths:

import { IconDisplay, IconSet, Icon } from "lucide.slint";
import { LabIconSet } from "lucide-lab.slint";

Here is what each item provides:

  • IconDisplay: A component that accepts and displays an Icon.

  • IconSet and LabIconSet: Two icon sets. IconSet contains the standard Lucide icons, while LabIconSet contains the Lucide Lab icons. Both are Slint globals:

    export global IconSet {
        out property <Icon> SlashSquare: {
            paths: [
                {
                  ..path data
                }
            ],
        };
    }

    You can access an icon with an expression such as IconSet.SlashSquare.

  • Icon: A struct that contains the path data for one icon. Its definition looks like this:

    export struct Icon {
        paths: [PathElem],
    }

Find an Icon

You can search for an icon on the Lucide Icons website.

To use it in Lucide Slint, follow these steps:

  1. Click an icon to open its details.
    a-arrow-down

  2. Click the button to expand the copy menu.

  3. Select Copy Component Name to get the icon's PascalCase name.
    AArrowDown

Image guide for the operations

Then use its PascalCase name with IconSet:

export component Example {
    IconDisplay {
        icon: IconSet.AArrowDown;
    }
}

Display an Icon

The examples below are interactive. Their imports are hidden, but the copied code includes them.

Choose and Style an Icon

Set icon to an icon from IconSet. Then use these properties to adjust its appearance:

  • stroke: Accepts a brush and sets the stroke color. Examples include transparent, red, #ffffff, red.mix(blue, 50%), and @linear-gradient().

  • stroke-width: Accepts a float and sets the base stroke width.

  • size: Accepts a length and sets both the width and height of the icon.

stroke

export component Example {
    IconDisplay {
        icon: IconSet.FilePlay;
        stroke: #ff9b00;
        stroke-width: 1.5;
        size: 100px;
    }
}

Fill an Icon

The stroke-fill property accepts a brush and sets the fill color. Its default value is transparent.

stroke
stroke-fill

export component Example {
    IconDisplay {
        icon: IconSet.TreeDeciduous;
        stroke: #7faf6a;
        stroke-fill: #a1c88f;
    }
}

stroke-fill only applies to paths that were not already filled in the source SVG, and it is visible only when a path encloses an area. Some icons may not look good when filled.

Control Stroke Scaling

Absolute stroke width controls whether the stroke scales with the icon. The two icons below share the same size and stroke-width; only absolute-stroke-width differs. Adjust either value to compare them.

false scales with size
true stays constant

When absolute-stroke-width is false, the stroke width scales with size. This keeps the stroke and icon proportions the same:

calculated-stroke-width: size / 24 * stroke-width

When absolute-stroke-width is true, the stroke no longer scales with the icon. The value of stroke-width is used directly in logical pixels:

calculated-stroke-width: stroke-width * 1px

Use the playground below to try the property.


export component Example {
    IconDisplay {
        icon: IconSet.Search;
        size: 100px;
        stroke-width: 2;
        absolute-stroke-width: false;
    }
}

Tips

Beyond these properties, a few patterns can make Lucide Slint easier to use.

Reuse a Custom Icon Style

When several icons share the same custom style, inherit from IconDisplay to make that style the default:

import { IconDisplay, IconSet } from "@lucide";

export component MyIconDisplay inherits IconDisplay {
    stroke: #8e8cd8;
    stroke-width: 1.5;

    animate stroke, stroke-fill {
        duration: 300ms;
        easing: ease-in-out;
    }
}

export component Example {
    VerticalLayout {
        MyIconDisplay {
            icon: IconSet.NotebookPen;
        }

        MyIconDisplay {
            icon: IconSet.LampDesk;
        }
    }
}

Use Icons in Components

When a component accepts an image as an optional property, it can check the image's dimensions to tell whether the caller provided one. This lets the component create an Image only when there is something to display:

export component ImageButton {
    in property <image> icon;

    if (root.icon.width > 0 && root.icon.height > 0): Image {
        source: root.icon;
    }
}

Lucide Slint's Icon supports a similar check. An empty Icon has no paths, so you can check paths.length and create an IconDisplay only when the caller provided an icon:

import { IconDisplay, Icon } from "@lucide";

export component IconButton {
    in property <Icon> icon;

    if (root.icon.paths.length > 0): IconDisplay {
        icon: root.icon;
    }
}

Configure Your IDE

When using an IDE with the Slint plugin or slint-lsp, you may see these errors:

2 errors produced in the PROBLEMS panel: Cannot find requested import "@lucide" in the library search path; Cannot find requested import "@lucide-lab" in the library search path

These errors appear because slint-lsp does not share the library-path configuration from your project's build.rs. Your application may compile successfully while the language server still cannot resolve @lucide and @lucide-lab.

You must configure the same library paths separately for slint-lsp. With Cargo, this is awkward because registry paths include versioned directories.

You can get the files in either of these ways:

  • Download the .slint files manually and store them in a stable location on your computer.

  • Find the installed Lucide Slint crate under .cargo/registry/src/index.crates.io-xxx/lucide-slint-xxx/.

Then add these settings to your VS Code settings.json:

{
    "slint.libraryPaths": {
        "lucide": "path/to/lucide.slint",
        "lucide-lab": "path/to/lucide-lab.slint"
    }
}

If you use a JetBrains IDE with the Slint plugin, open Settings > Languages & Frameworks > Slint.

Add the following to Args. These arguments will be passed to slint-lsp:

-L lucide=path/to/lucide.slint -L lucide-lab=path/to/lucide-lab.slint

You usually do not need to update the files used by slint-lsp for every Lucide Slint release. Most releases update the icon set, while major library changes are less frequent.

When Image Is the Better Choice

A Lucide Slint Icon contains path data, not a Slint image. It cannot be used as the source of an Image, and Lucide Slint is not designed to convert it into one.

Some features eventually pass their icon to a system API that requires bitmap data. SystemTrayIcon is one example. A Slint image is the right choice in these cases, and its source can be a bitmap or SVG. If the target size is known, providing a bitmap at that size avoids rasterizing an SVG at runtime. Tray icons are small, so a correctly sized bitmap will usually stay sharp.

The following issue shows this mismatch in practice. Its author wanted ContextMenuArea to accept path data so that a custom menu would not be necessary. However, its menu icons ultimately use the fixed image type. The request was closed as not planned:

MenuItem icon should be able to accept a Path

Many Slint components use image properties, so making all of them accept Path data is not realistic. If you need Lucide icons, create your own component or copy and adapt an existing implementation. Slint's internal Fluent widgets and Material components are useful references. In your version, replace the image properties with Icon properties and the Image elements with IconDisplay.

This takes a little extra work, but it is worth it.

Emotion Showing Off

That's all for today! This was a long post, and it took me about a week to write. I plan to keep it updated as Lucide Slint evolves. Thanks for reading!

Bonus: A playground for every property!

stroke
stroke-fill

export component Example {
    IconDisplay {
        icon: IconSet.Star;
        size: 100px;
        stroke: #ff9b00;
        stroke-fill: transparent;
        stroke-width: 1.5;
        absolute-stroke-width: false;
    }
}

Comments