diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst
index 3c9e42bdd02..6667f902267 100644
--- a/src/Icons/doc/index.rst
+++ b/src/Icons/doc/index.rst
@@ -481,6 +481,51 @@ of the following attributes: ``aria-label``, ``aria-labelledby`` or ``title``.
+**Accessibility: Descriptive Titles and Descriptions**
+
+.. versionadded:: 2.28
+
+The `ux_icon()` function and the `` component now support accessible SVG metadata via the `title` and `desc` attributes in 2.28.
+
+These are automatically injected into the ```` markup as child elements, and properly referenced using ``aria-labelledby`` for improved screen reader support.
+
+**How it works:**
+
+When you pass a `title` and/or `desc` attribute, they are rendered inside the `` as follows:
+
+.. code-block:: twig
+
+{{ ux_icon('bi:plus-square-dotted', {
+ width: '16px',
+ height: '16px',
+ class: 'text-success',
+ title: 'Add Stock',
+ desc: 'This icon indicates stock entry functionality.'
+}) }}
+
+Renders:
+
+.. code-block:: html
+
+
+ Add Stock
+ This icon indicates stock entry functionality.
+
+
+
+.. note::
+
+ - If ``aria-labelledby`` is already defined in your attributes, it will **not** be overwritten.
+ - ``role="img"`` is **not added automatically**. You may choose to include it if your use case requires.
+ - When neither ``title``, ``desc``, ``aria-label``, nor ``aria-labelledby`` are provided, ``aria-hidden="true"`` will still be automatically applied.
+
+This feature brings UX Icons in line with modern accessibility recommendations and helps developers build more inclusive user interfaces.
+
+To learn more about accessible SVG elements:
+
+- `MDN: `\_ — [https://developer.mozilla.org/en-US/docs/Web/SVG/Element/title](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/title)
+- `MDN: `\_ — [https://developer.mozilla.org/en-US/docs/Web/SVG/Element/desc](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/desc)
+
Performance
-----------
diff --git a/src/Icons/src/Icon.php b/src/Icons/src/Icon.php
index e7a9e73b04e..69c0c8001f4 100644
--- a/src/Icons/src/Icon.php
+++ b/src/Icons/src/Icon.php
@@ -139,7 +139,45 @@ public function __construct(
public function toHtml(): string
{
$htmlAttributes = '';
- foreach ($this->attributes as $name => $value) {
+ $innerSvg = $this->innerSvg;
+ $attributes = $this->attributes;
+
+ // Extract and remove title/desc attributes if present
+ $title = $attributes['title'] ?? null;
+ $desc = $attributes['desc'] ?? null;
+ unset($attributes['title'], $attributes['desc']);
+
+ $labelledByIds = [];
+ $a11yContent = '';
+
+ // Check if aria-labelledby should be added automatically
+ $shouldSetLabelledBy = !isset($attributes['aria-labelledby']) && ($title || $desc);
+
+ if ($title) {
+ if ($shouldSetLabelledBy) {
+ $titleId = 'title-' . bin2hex(random_bytes(4));
+ $labelledByIds[] = $titleId;
+ $a11yContent .= sprintf('%s ', $titleId, htmlspecialchars((string) $title, ENT_QUOTES));
+ } else {
+ $a11yContent .= sprintf('%s ', htmlspecialchars((string) $title, ENT_QUOTES));
+ }
+ }
+
+ if ($desc) {
+ if ($shouldSetLabelledBy) {
+ $descId = 'desc-' . bin2hex(random_bytes(4));
+ $labelledByIds[] = $descId;
+ $a11yContent .= sprintf('%s ', $descId, htmlspecialchars((string) $desc, ENT_QUOTES));
+ } else {
+ $a11yContent .= sprintf('%s ', htmlspecialchars((string) $desc, ENT_QUOTES));
+ }
+ }
+
+ if ($shouldSetLabelledBy) {
+ $attributes['aria-labelledby'] = implode(' ', $labelledByIds);
+ }
+
+ foreach ($attributes as $name => $value) {
if (false === $value) {
continue;
}
@@ -159,7 +197,7 @@ public function toHtml(): string
$htmlAttributes .= '="'.$value.'"';
}
- return ''.$this->innerSvg.' ';
+ return '' . $a11yContent . $innerSvg . ' ';
}
public function getInnerSvg(): string
diff --git a/src/Icons/tests/Unit/IconAccessibilityTest.php b/src/Icons/tests/Unit/IconAccessibilityTest.php
new file mode 100644
index 00000000000..705f39aebb2
--- /dev/null
+++ b/src/Icons/tests/Unit/IconAccessibilityTest.php
@@ -0,0 +1,44 @@
+', ['title' => 'Test Icon']);
+ $html = $icon->toHtml();
+ $this->assertMatchesRegularExpression('/Test Icon<\/title>/', $html);
+ }
+
+ public function testDescIsIncludedInOutput()
+ {
+ $icon = new Icon(' ', ['desc' => 'This is a test circle']);
+ $html = $icon->toHtml();
+ $this->assertMatchesRegularExpression('/This is a test circle<\/desc>/', $html);
+ }
+
+ public function testTitleAndDescWithCustomAriaLabelledBy()
+ {
+ $attributes = [
+ 'title' => 'My Line',
+ 'desc' => 'This is a diagonal line',
+ 'aria-labelledby' => 'custom-id',
+ ];
+ $icon = new Icon(' ', $attributes);
+
+ $html = $icon->toHtml();
+ $this->assertStringContainsString('My Line ', $html);
+ $this->assertStringContainsString('This is a diagonal line ', $html);
+ $this->assertStringContainsString('aria-labelledby="custom-id"', $html);
+ }
+
+ public function testToStringReturnsHtml()
+ {
+ $icon = new Icon(' ');
+ $this->assertSame($icon->toHtml(), (string) $icon);
+ }
+}