diff --git a/extra/html-extra/HtmlAttributes.php b/extra/html-extra/HtmlAttributes.php
new file mode 100644
index 0000000000..a23eceda24
--- /dev/null
+++ b/extra/html-extra/HtmlAttributes.php
@@ -0,0 +1,221 @@
+ 'a', 'disabled' => true], ['hidden' => true])` becomes
+ * `['id' => 'a', 'disabled' => true, 'hidden' => true]`
+ *
+ * attributes override each other in the order they are provided.
+ *
+ * `HtmlAttributes::merge(['id' => 'a'], ['id' => 'b'])` becomes `['id' => 'b']`.
+ *
+ * However, `class` and `style` attributes are merged into an array so they can be concatenated in later processing.
+ *
+ * `HtmlAttributes::merge(['class' => 'a'], ['class' => 'b'], ['class' => 'c'])` becomes
+ * `['class' => ['a' => true, 'b' => true, 'c' => true]]`.
+ *
+ * style attributes are also merged into an array so they can be concatenated in later processing.
+ *
+ * `HtmlAttributes::merge(['style' => 'color: red'], ['style' => ['background-color' => 'blue']])` becomes
+ * `['style' => ['color: red;' => true, 'background-color: blue;' => true]]`.
+ *
+ * style attributes which are arrays with false and null values are also processed
+ *
+ * `HtmlAttributes::merge(['style' => ['color: red' => true]], ['style' => ['display: block' => false]]) becomes
+ * `['style' => ['color: red;' => true, 'display: block;' => false]]`.
+ *
+ * attributes can be provided as an array of key, value where the value can be true, false or null.
+ *
+ * Example:
+ * `HtmlAttributes::merge(['class' => ['a' => true, 'b' => false], ['class' => ['c' => null']])` becomes
+ * `['class' => ['a' => true, 'b' => false, 'c' => null]]`.
+ *
+ * `aria` and `data` arrays are expanded into `aria-*` and `data-*` attributes before further processing.
+ *
+ * Example:
+ *
+ * `HtmlAttributes::merge([data' => ['count' => '1']])` becomes `['data-count' => '1']`.
+ * `HtmlAttributes::merge(['aria' => ['hidden' => true]])` becomes `['aria-hidden' => true]`.
+ *
+ * @param ...$attributeGroup
+ * @return array
+ * @throws RuntimeError
+ * @see ./Tests/HtmlAttributesTest.php for usage examples
+ *
+ */
+ public static function merge(...$attributeGroup): array
+ {
+ $result = [];
+
+ $attributeGroupCount = 0;
+
+ foreach ($attributeGroup as $attributes) {
+
+ $attributeGroupCount++;
+
+ // Skip empty attributes
+ // Return early if no attributes are provided
+ // This could be false or null when using the twig ternary operator
+ if (!$attributes) {
+ continue;
+ }
+
+ if (!is_iterable($attributes)) {
+ throw new RuntimeError(sprintf('"%s" only works with mappings or "Traversable", got "%s" for argument %d.', self::class, \gettype($attributes), $attributeGroupCount));
+ }
+
+ // Alternative to is_iterable check above, cast the attributes to an array
+ // This would produce weird results but would not throw an error
+// $attributes = (array)$attributes;
+
+ // data and aria arrays are expanded into data-* and aria-* attributes
+ $expanded = [];
+ foreach ($attributes as $key => $value) {
+ if (in_array($key, ['data', 'aria'])) {
+ $value = (array)$value;
+ foreach ($value as $k => $v) {
+ $k = $key . '-' . $k;
+ $expanded[$k] = $v;
+ }
+ continue;
+ }
+ $expanded[$key] = $value;
+ }
+
+ // Reset the attributes array to the flattened version
+ $attributes = $expanded;
+
+ foreach ($attributes as $key => $value) {
+
+ // Treat class and data-controller attributes as arrays
+ if (in_array($key, [
+ 'class',
+ 'data-controller',
+ 'data-action',
+ 'data-targets',
+ ])) {
+ if (!array_key_exists($key, $result)) {
+ $result[$key] = [];
+ }
+ $value = (array)$value;
+ foreach ($value as $k => $v) {
+ if (is_int($k)) {
+ $classes = explode(' ', $v);
+ foreach ($classes as $class) {
+ $result[$key][$class] = true;
+ }
+ } else {
+ $classes = explode(' ', $k);
+ foreach ($classes as $class) {
+ $result[$key][$class] = $v;
+ }
+ }
+ }
+ continue;
+ }
+
+ if ($key === 'style') {
+ if (!array_key_exists('style', $result)) {
+ $result['style'] = [];
+ }
+ $value = (array)$value;
+ foreach ($value as $k => $v) {
+ if (is_int($k)) {
+ $styles = array_filter(explode(';', $v));
+ foreach ($styles as $style) {
+ $style = explode(':', $style);
+ $sKey = trim($style[0]);
+ $sValue = trim($style[1]);
+ $result['style']["$sKey: $sValue;"] = true;
+ }
+ } elseif (is_bool($v) || is_null($v)) {
+ $styles = array_filter(explode(';', $k));
+ foreach ($styles as $style) {
+ $style = explode(':', $style);
+ $sKey = trim($style[0]);
+ $sValue = trim($style[1]);
+ $result['style']["$sKey: $sValue;"] = $v;
+ }
+ } else {
+ $sKey = trim($k);
+ $sValue = trim($v);
+ $result['style']["$sKey: $sValue;"] = true;
+ }
+ }
+ continue;
+ }
+
+ $result[$key] = $value;
+ }
+ }
+
+ return $result;
+ }
+
+ public static function renderAttributes($attributes): string
+ {
+ $return = [];
+
+ foreach ($attributes as $key => $value) {
+
+ // Skip null values regardless of attribute key
+ if ($value === null) {
+ continue;
+ }
+
+ // Handle class, style, data-controller value coercion
+ // array[] -> concatenate string
+ if (in_array($key, ['class', 'style', 'data-controller'])) {
+ $value = array_filter($value);
+ $value = array_keys($value);
+ $value = implode(' ', $value);
+ }
+
+ // Handle aria-* value coercion
+ // true -> 'true'
+ // false -> 'false,
+ // array[] -> concatenate string
+ if (str_starts_with($key, 'aria-')) {
+ if ($value === true) {
+ $value = 'true';
+ } elseif ($value === false) {
+ $value = 'false';
+ } elseif(is_array($value)) {
+ $value = join(" ", array_filter($value));
+ }
+
+ }
+
+ // Handle data-* value coercion
+ // array[] -> json
+ if (str_starts_with($key, 'data-')) {
+ if(is_array($value)) {
+ $value = json_encode($value);
+ }
+ }
+
+ // Skip false values
+ if ($value === false) {
+ continue;
+ }
+
+ // Boolean attribute doesn't have a value
+ if ($value === true) {
+ $return[] = $key;
+ continue;
+ }
+
+ // Everything else gets added as an encoded value
+ $return[] = $key . '="' . htmlspecialchars($value) . '"';
+ }
+
+ return implode(' ', $return);
+ }
+}
\ No newline at end of file
diff --git a/extra/html-extra/Tests/HtmlAttributesTest.php b/extra/html-extra/Tests/HtmlAttributesTest.php
new file mode 100644
index 0000000000..5c8130bca0
--- /dev/null
+++ b/extra/html-extra/Tests/HtmlAttributesTest.php
@@ -0,0 +1,242 @@
+expectException(\Twig\Error\RuntimeError::class);
+ $result = HtmlAttributes::merge(['class' => 'a'], 'b');
+ }
+
+ /**
+ * Tests output of HtmlAttributes::merge() method can be used as an array of attributes.
+ * @return void
+ * @throws RuntimeError
+ */
+ public function testMultipleMerge()
+ {
+ $result1 = HtmlAttributes::merge(['a' => 'b', 'c' => 'd'],
+ true ? ['e' => 'f'] : null,
+ false ? ['g' => 'h'] : null,
+ ['i' => true],
+ ['j' => true],
+ ['j' => false],
+ ['k' => true],
+ ['k' => null]
+ );
+
+ $result2 = HtmlAttributes::merge(
+ ['class' => 'a b j'],
+ ['class' => ['c', 'd', 'e f']],
+ ['class' => ['g' => true, 'h' => false, 'i' => true]],
+ ['class' => ['h' => true]],
+ ['class' => ['i' => false]],
+ ['class' => ['j' => null]],
+ );
+
+ $result = HtmlAttributes::merge($result1, $result2);
+
+ self::assertSame([
+ 'a' => 'b',
+ 'c' => 'd',
+ 'e' => 'f',
+ 'i' => true,
+ 'j' => false,
+ 'k' => null,
+ 'class' => [
+ 'a' => true,
+ 'b' => true,
+ 'j' => null,
+ 'c' => true,
+ 'd' => true,
+ 'e' => true,
+ 'f' => true,
+ 'g' => true,
+ 'h' => true,
+ 'i' => false,
+ ]
+ ], $result);
+ }
+
+ public function htmlAttrProvider(): \Generator
+ {
+ yield 'merging basic attributes' => [
+ [
+ ['a' => 'b', 'c' => 'd'],
+ true ? ['e' => 'f'] : null,
+ false ? ['g' => 'h'] : null,
+ ['i' => true],
+ ['j' => true],
+ ['j' => false],
+ ['k' => true],
+ ['k' => null],
+ ],
+ [
+ 'a' => 'b',
+ 'c' => 'd',
+ 'e' => 'f',
+ 'i' => true,
+ 'j' => false,
+ 'k' => null
+ ],
+ 'a="b" c="d" e="f" i',
+ ];
+
+ /**
+ * class attributes are merged into an array so they can be concatenated in later processing.
+ */
+ yield 'merging class attributes' => [
+ [
+ ['class' => 'a b j'],
+ ['class' => ['c', 'd', 'e f']],
+ ['class' => ['g' => true, 'h' => false, 'i' => true]],
+ ['class' => ['h' => true]],
+ ['class' => ['i' => false]],
+ ['class' => ['j' => null]],
+ ],
+ ['class' => [
+ 'a' => true,
+ 'b' => true,
+ 'j' => null,
+ 'c' => true,
+ 'd' => true,
+ 'e' => true,
+ 'f' => true,
+ 'g' => true,
+ 'h' => true,
+ 'i' => false,
+ ]],
+ 'class="a b c d e f g h"',
+ ];
+
+ /**
+ * style attributes are merged into an array so they can be concatenated in later processing.
+ * // Strings are true by default.
+ * `HtmlAttributes::merge(['color: red']) === ['color: red' => true]`
+ * // Arrays have a boolean / null value
+ * `HtmlAttributes::merge(['color: red' => true ]) === ['color: red' => true]`
+ * `HtmlAttributes::merge(['color: red' => false ]) === ['color: red' => false]`
+ * String values are split into key value pairs and then processed
+ * `HtmlAttributes::merge(['color: red; background: blue']) === ['color: red' => true, 'background: blue' => true]`
+ * `HtmlAttributes::merge(['color: red; background: blue' => true]) === ['color: red' => true, 'background: blue' => true]`
+ */
+ yield 'merging style attributes' => [
+ [
+ ['style' => 'a: b;'],
+ ['style' => ['c' => 'd', 'e' => 'f']],
+ ['style' => ['g: h;']],
+ ['style' => [
+ 'i: j; k: l' => true,
+ 'm: n' => false,
+ 'o: p' => null
+ ]],
+ ],
+ ['style' => [
+ 'a: b;' => true,
+ 'c: d;' => true,
+ 'e: f;' => true,
+ 'g: h;' => true,
+ 'i: j;' => true,
+ 'k: l;' => true,
+ 'm: n;' => false,
+ 'o: p;' => null,
+ ]],
+ 'style="a: b; c: d; e: f; g: h; i: j; k: l;"',
+ ];
+
+ /**
+ * `data` arrays are expanded into `data-*` attributes before further processing.
+ */
+ yield 'merging data-* attributes' => [
+ [
+ ['data-string' => 'a'],
+ ['data-int' => 100],
+ ['data-bool-true' => true],
+ ['data-bool-false' => false],
+ ['data-null' => null],
+ ['data-array' => ['a' => 'b']],
+ ['data' => ['expanded-0' => true, 'expanded-1' => false, 'expanded-2' => null]],
+ ],
+ [
+ 'data-string' => 'a',
+ 'data-int' => 100,
+ 'data-bool-true' => true,
+ 'data-bool-false' => false,
+ 'data-null' => null,
+ 'data-array' => ['a' => 'b'],
+ 'data-expanded-0' => true,
+ 'data-expanded-1' => false,
+ 'data-expanded-2' => null,
+ ],
+ 'data-string="a" data-int="100" data-bool-true data-array="{"a":"b"}" data-expanded-0'
+ ];
+
+ /**
+ * `aria` arrays are expanded into `aria-*` attributes before further processing.
+ */
+ yield 'merging aria-* attributes' => [
+ [
+ ['aria-string' => 'a'],
+ ['aria-int' => 100],
+ ['aria-bool-true' => true],
+ ['aria-bool-false' => false],
+ ['aria-null' => null],
+ ['aria-array' => ['a', 'b']],
+ ['aria' => ['expanded-0' => true, 'expanded-1' => false, 'expanded-2' => null]],
+ ],
+ [
+ 'aria-string' => 'a',
+ 'aria-int' => 100,
+ 'aria-bool-true' => true,
+ 'aria-bool-false' => false,
+ 'aria-null' => null,
+ 'aria-array' => ['a', 'b'],
+ 'aria-expanded-0' => true,
+ 'aria-expanded-1' => false,
+ 'aria-expanded-2' => null,
+ ],
+ 'aria-string="a" aria-int="100" aria-bool-true="true" aria-bool-false="false" aria-array="a b" aria-expanded-0="true" aria-expanded-1="false"'
+ ];
+
+ yield 'merging data-controller attributes' => [
+ [
+ ['data' => ['controller' => 'c1 c2']],
+ ['data-controller' => 'c3'],
+ ['data-controller' => ['c4' => true]],
+ ['data-controller' => ['c5' => false]],
+ ['data-controller' => ['c6' => null]],
+ ],
+ [
+ 'data-controller' => [
+ 'c1' => true,
+ 'c2' => true,
+ 'c3' => true,
+ 'c4' => true,
+ 'c5' => false,
+ 'c6' => null
+ ],
+ ],
+ 'data-controller="c1 c2 c3 c4"'
+ ];
+
+
+ }
+}