diff --git a/demo/GraniteDemo.vala b/demo/GraniteDemo.vala
index e1b817367..b202832a2 100644
--- a/demo/GraniteDemo.vala
+++ b/demo/GraniteDemo.vala
@@ -43,6 +43,7 @@ public class Granite.Demo : Gtk.Application {
main_stack.add_titled (lists_view, "lists", "Lists & Grids");
main_stack.add_titled (accel_label_view, "accel_label", "AccelLabel");
main_stack.add_titled (css_view, "css", "Style Classes");
+ main_stack.add_titled (new SymbolView (), "symbols", "Symbols");
main_stack.add_titled (date_time_picker_view, "pickers", "Date & Time");
main_stack.add_titled (form_view, "formview", "Forms");
main_stack.add_titled (hypertext_view, "hypertextview", "HyperTextView");
diff --git a/demo/Views/SymbolView.vala b/demo/Views/SymbolView.vala
new file mode 100644
index 000000000..c50e94525
--- /dev/null
+++ b/demo/Views/SymbolView.vala
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2026 elementary, Inc. (https://elementary.io)
+ * SPDX-License-Identifier: LGPL-3.0-or-later
+ */
+
+public class SymbolView : DemoPage {
+ construct {
+ title = "Granite.Symbol";
+
+ var symbol_button = new SymbolButton (AUDIO_VOLUME);
+
+ var flowbox = new Gtk.FlowBox () {
+ column_spacing = 12,
+ row_spacing = 12,
+ height_request = 256
+ };
+ flowbox.append (symbol_button);
+ flowbox.add_css_class (Granite.CssClass.CARD);
+
+ var size_header = new Granite.HeaderLabel ("Size");
+
+ var size_scale = new Gtk.Scale.with_range (HORIZONTAL, 16, 128, 1) {
+ draw_value = true,
+ hexpand = true
+ };
+ size_scale.adjustment.value = 32;
+
+ var weight_header = new Granite.HeaderLabel ("Weight");
+
+ var weight_scale = new Gtk.Scale.with_range (HORIZONTAL, 100, 800, 100) {
+ draw_value = true,
+ hexpand = true
+ };
+ weight_scale.adjustment.value = 300;
+
+ var main_box = new Granite.Box (VERTICAL) {
+ margin_top = 12,
+ margin_start = 12,
+ margin_end = 12,
+ margin_bottom = 12
+ };
+ main_box.append (flowbox);
+ main_box.append (size_header);
+ main_box.append (size_scale);
+ main_box.append (weight_header);
+ main_box.append (weight_scale);
+
+ child = main_box;
+
+ size_scale.adjustment.bind_property ("value", symbol_button, "pixel-size", SYNC_CREATE);
+ weight_scale.adjustment.bind_property ("value", symbol_button, "weight", SYNC_CREATE);
+ }
+
+ private class SymbolButton : Gtk.Button {
+ public int pixel_size { get; set; }
+ public double weight { get; set; }
+ public Granite.SymbolName symbol_name { get; construct; }
+
+ public SymbolButton (Granite.SymbolName symbol_name) {
+ Object (symbol_name: symbol_name);
+ }
+
+ construct {
+ var symbol = new Granite.Symbol (symbol_name);
+
+ child = symbol;
+ add_css_class ("image-button");
+
+ bind_property ("pixel-size", symbol, "pixel-size", SYNC_CREATE);
+ bind_property ("weight", symbol, "weight", SYNC_CREATE);
+
+ clicked.connect (() => {
+ if (symbol.state_index == symbol.states_length - 1) {
+ symbol.state_index = 0;
+ return;
+ }
+
+ symbol.state_index++;
+ });
+ }
+ }
+}
diff --git a/demo/meson.build b/demo/meson.build
index f39e416f7..12472c475 100644
--- a/demo/meson.build
+++ b/demo/meson.build
@@ -21,6 +21,7 @@ executable(
'Views/MapsView.vala',
'Views/OverlayBarView.vala',
'Views/SettingsUrisView.vala',
+ 'Views/SymbolView.vala',
'Views/ToastView.vala',
'Views/UtilsView.vala',
'Views/VideoView.vala',
diff --git a/lib/Symbols/CONTRIBUTING.md b/lib/Symbols/CONTRIBUTING.md
new file mode 100644
index 000000000..87c26df45
--- /dev/null
+++ b/lib/Symbols/CONTRIBUTING.md
@@ -0,0 +1,22 @@
+## Canvas Size
+
+16px x 16px
+
+## Fill
+
+10% opacity
+
+## Strokes
+
+Main stroke width is 1px
+
+## Corner Radius
+
+outside corners 0.5px
+inside corners 0.25px
+
+## States
+
+All symbols must have `normal` and `disabled` states
+
+symbols can have additional custom states
diff --git a/lib/Symbols/audio-volume.svg b/lib/Symbols/audio-volume.svg
new file mode 100644
index 000000000..2b5d1b262
--- /dev/null
+++ b/lib/Symbols/audio-volume.svg
@@ -0,0 +1,295 @@
+
diff --git a/lib/Symbols/meson.build b/lib/Symbols/meson.build
new file mode 100644
index 000000000..cfd349167
--- /dev/null
+++ b/lib/Symbols/meson.build
@@ -0,0 +1,4 @@
+symbols_resource = gnome.compile_resources(
+ 'symbols-resource',
+ 'symbols.gresource.xml'
+)
diff --git a/lib/Symbols/symbols.gresource.xml b/lib/Symbols/symbols.gresource.xml
new file mode 100644
index 000000000..2e4ef70e1
--- /dev/null
+++ b/lib/Symbols/symbols.gresource.xml
@@ -0,0 +1,6 @@
+
+
+
+ audio-volume.svg
+
+
diff --git a/lib/Widgets/Symbol.vala b/lib/Widgets/Symbol.vala
new file mode 100644
index 000000000..8fb347330
--- /dev/null
+++ b/lib/Widgets/Symbol.vala
@@ -0,0 +1,109 @@
+/*
+* SPDX-License-Identifier: LGPL-3.0-or-later
+* SPDX-FileCopyrightText: 2026 elementary, Inc. (https://elementary.io)
+*/
+
+namespace Granite.SymbolState {
+ // The default state
+ public const string NORMAL = "normal";
+ // Disabled state represented by a slash
+ public const string DISABLED = "disabled";
+ // e.g. paired, connected, needs attention
+ public const string ACTIVE = "active";
+}
+
+public enum Granite.SymbolName {
+ // Audio volume level
+ AUDIO_VOLUME;
+
+ public string to_path () {
+ var resource_base_path = "/io/elementary/granite/";
+ switch (this) {
+ case AUDIO_VOLUME:
+ return resource_base_path + "audio-volume.svg";
+ }
+
+ // FIXME: image-missing
+ return "";
+ }
+}
+
+public class Granite.Symbol : Granite.Bin {
+ public string resource_path { get; construct; }
+
+ public int pixel_size {
+ get { return image.pixel_size; }
+ set { image.pixel_size = value; }
+ }
+
+ public double weight {
+ get { return svg.weight; }
+ set { svg.weight = value; }
+ }
+
+ public uint states_length {
+ get {
+ uint length = -1;
+ svg.get_state_names (out length);
+
+ return length;
+ }
+ }
+
+ public uint state_index {
+ get { return svg.state; }
+ set {
+ if (value > states_length - 1) {
+ warning ("Granite.Symbol set to undefined state. Ignoring.");
+ return;
+ }
+
+ svg.state = value;
+ notify_property ("state");
+ }
+ }
+
+ public string state {
+ get {
+ uint length = -1;
+ return svg.get_state_names (out length)[state_index];
+ }
+ set {
+ uint length = -1;
+ var names = svg.get_state_names (out length);
+
+ for (int i = 0; i < length; i++) {
+ if (names[i] == value) {
+ state_index = i;
+ return;
+ }
+ }
+
+ warning ("Granite.Symbol set to undefined state. Ignoring.");
+ }
+ }
+
+ private Gtk.Image image;
+ private Gtk.Svg svg;
+
+ public Symbol (SymbolName name) {
+ Object (resource_path: name.to_path ());
+ }
+
+ public Symbol.from_resource (string resource_path) {
+ Object (resource_path: resource_path);
+ }
+
+ construct {
+ svg = new Gtk.Svg.from_resource (resource_path);
+
+ image = new Gtk.Image.from_paintable (svg);
+
+ child = image;
+
+ child.realize.connect (() => {
+ svg.set_frame_clock (get_frame_clock ());
+ svg.play ();
+ });
+ }
+}
diff --git a/lib/meson.build b/lib/meson.build
index 57d6ae8ae..98e94cad7 100644
--- a/lib/meson.build
+++ b/lib/meson.build
@@ -1,5 +1,6 @@
subdir('Icons')
subdir('Styles')
+subdir('Symbols')
libgranite_sources = files(
'Animation.vala',
@@ -30,6 +31,7 @@ libgranite_sources = files(
'Widgets/Placeholder.vala',
'Widgets/Settings.vala',
'Widgets/SwitchModelButton.vala',
+ 'Widgets/Symbol.vala',
'Widgets/TimePicker.vala',
'Widgets/ToolBox.vala',
'Widgets/Toast.vala',
@@ -67,6 +69,7 @@ libgranite = library(
libgranite_sources,
icons_resource,
stylesheet_resource,
+ symbols_resource,
config_vala,
dependencies: [