diff --git a/config/services.yaml b/config/services.yaml
index 3854036..58a444d 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -52,6 +52,10 @@ services:
tags:
- { name: 'twig.component', key: 'Enabel:Ux:Timeline', template: '@EnabelUx/timeline/timeline.html.twig', expose_public_props: true }
+ Enabel\Ux\Component\Toast\Toast:
+ tags:
+ - { name: 'twig.component', key: 'Enabel:Ux:Toast', template: '@EnabelUx/toast/toast.html.twig', expose_public_props: true }
+
Enabel\Ux\Component\Widget\Widget:
tags:
- { name: 'twig.component', key: 'Enabel:Ux:Widget', template: '@EnabelUx/widget/widget.html.twig', expose_public_props: true }
diff --git a/docs/Toast/toast.md b/docs/Toast/toast.md
new file mode 100644
index 0000000..840d3ff
--- /dev/null
+++ b/docs/Toast/toast.md
@@ -0,0 +1,116 @@
+# Toast Component
+
+## Description
+
+A Bootstrap 5 toast component for non-intrusive notifications (flash messages, status updates). Renders a `.toast` element styled with `text-bg-{type}`, with optional dismiss button, auto-hide and configurable politeness for screen readers.
+
+The component renders the markup only — Bootstrap's toast must be initialized client-side (via `bootstrap.Toast.getOrCreateInstance(el).show()` or any equivalent), exactly like the native Bootstrap toast.
+
+## Parameters
+
+| Parameter | Type | Description | Default |
+|:--------------|:----------|:---------------------------------------------------------------------------------------------|:----------|
+| `text` | `?string` | The message text rendered in the toast body | `null` |
+| `type` | `string` | Bootstrap color: `primary`, `secondary`, `success`, `danger`, `warning`, `info`, `light`, `dark` | `'info'` |
+| `dismissible` | `bool` | Show a close button | `true` |
+| `autohide` | `bool` | Map to Bootstrap `data-bs-autohide` | `true` |
+| `delay` | `int` | Auto-hide delay in milliseconds (Bootstrap `data-bs-delay`) | `5000` |
+| `politeness` | `string` | `polite` or `assertive`. Maps to `aria-live` | `'polite'`|
+
+## Accessibility notes
+
+The component renders `role="alert"` with `aria-live="{politeness}"` and `aria-atomic="true"`.
+
+- Use `politeness: 'polite'` (default) for non-urgent updates so the screen reader announces them at the next pause (success, info confirmations).
+- Use `politeness: 'assertive'` for urgent feedback that must interrupt (errors, warnings the user must act on).
+
+The close button gets `btn-close-white` automatically except on `warning` and `light` backgrounds (dark text on light fill).
+
+## Usage
+
+### Basic toast
+
+```twig
+{{ component('Enabel:Ux:Toast', {
+ text: 'Saved successfully',
+ type: 'success',
+}) }}
+```
+
+### Toast with custom delay
+
+```twig
+{{ component('Enabel:Ux:Toast', {
+ text: 'This will hide in 10 seconds',
+ type: 'info',
+ delay: 10000,
+}) }}
+```
+
+### Persistent toast (no auto-hide)
+
+```twig
+{{ component('Enabel:Ux:Toast', {
+ text: 'Read me carefully and dismiss when done',
+ type: 'warning',
+ autohide: false,
+}) }}
+```
+
+### Assertive toast for errors
+
+```twig
+{{ component('Enabel:Ux:Toast', {
+ text: 'Failed to save your changes',
+ type: 'danger',
+ politeness: 'assertive',
+}) }}
+```
+
+### Toast with rich content via the `content` block
+
+```twig
+{% component 'Enabel:Ux:Toast' with { type: 'success' } %}
+ {% block content %}
+ Saved. View it here.
+ {% endblock %}
+{% endcomponent %}
+```
+
+## Showing the toast (client-side)
+
+Like the native Bootstrap toast, the component renders the markup but does not display it. Initialize and show via Bootstrap's JS API. Example with a Stimulus controller covering all toasts inside a container:
+
+```js
+import { Controller } from '@hotwired/stimulus';
+import { Toast } from 'bootstrap';
+
+export default class extends Controller {
+ connect() {
+ this.element.querySelectorAll('.toast').forEach(el => {
+ Toast.getOrCreateInstance(el).show();
+ });
+ }
+}
+```
+
+```twig
+
+ {% for type, messages in app.flashes %}
+ {% for message in messages %}
+ {{ component('Enabel:Ux:Toast', { text: message, type: type }) }}
+ {% endfor %}
+ {% endfor %}
+
+```
+
+Bootstrap automatically stacks multiple toasts inside a `.toast-container` (margin between them) and handles fade-in/fade-out animations.
+
+## Flash type mapping (Symfony)
+
+Symfony's `addFlash()` accepts free-form types. To map them to Bootstrap colors before passing to the component:
+
+```twig
+{% set bsType = {success: 'success', error: 'danger', warning: 'warning', info: 'info', notice: 'info'}[type]|default('primary') %}
+{{ component('Enabel:Ux:Toast', { text: message, type: bsType }) }}
+```
diff --git a/docs/index.md b/docs/index.md
index acbd1fd..c0fc2ec 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -13,6 +13,7 @@ https://github.com/Enabel/Ux
- [Card](Card/card.md) - A Bootstrap card component for displaying content in a flexible and extensible container
- [Modal](Modal/modal.md) - Easily render modals from a dedicated controller
- [Timeline](Timeline/timeline.md) - A Bootstrap timeline component for displaying chronological events with icons and color-coded styling
+- [Toast](Toast/toast.md) - A Bootstrap toast component for non-intrusive notifications with auto-hide and accessibility-aware politeness
- [Widget](Widget/widget.md) - A Bootstrap widget component for displaying key metrics, statistics, and navigation elements in dashboard-style format
- **Navigation Components** - Bootstrap components for creating responsive navigation:
- [Navbar](Navigation/navbar.md) - Main navigation container with logo, name, and link configuration
diff --git a/src/Component/Toast/Toast.php b/src/Component/Toast/Toast.php
new file mode 100644
index 0000000..89702c3
--- /dev/null
+++ b/src/Component/Toast/Toast.php
@@ -0,0 +1,60 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Enabel\Ux\Component\Toast;
+
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\UX\TwigComponent\Attribute\PreMount;
+
+class Toast
+{
+ public ?string $text;
+ public string $type;
+ public bool $dismissible;
+ public bool $autohide;
+ public int $delay;
+ public string $politeness;
+
+ /**
+ * @param array $data
+ *
+ * @return array
+ */
+ #[PreMount]
+ public function preMount(array $data): array
+ {
+ $resolver = new OptionsResolver();
+ $this->configureOptions($resolver);
+
+ return $resolver->resolve($data) + $data;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setIgnoreUndefined();
+ $resolver->setDefaults([
+ 'text' => null,
+ 'type' => 'info',
+ 'dismissible' => true,
+ 'autohide' => true,
+ 'delay' => 5000,
+ 'politeness' => 'polite',
+ ]);
+
+ $resolver->setAllowedTypes('text', ['string', 'null']);
+ $resolver->setAllowedTypes('type', 'string');
+ $resolver->setAllowedTypes('dismissible', 'bool');
+ $resolver->setAllowedTypes('autohide', 'bool');
+ $resolver->setAllowedTypes('delay', 'int');
+ $resolver->setAllowedTypes('politeness', 'string');
+
+ $resolver->setAllowedValues('type', ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark']);
+ $resolver->setAllowedValues('politeness', ['polite', 'assertive']);
+ }
+}
diff --git a/templates/toast/toast.html.twig b/templates/toast/toast.html.twig
new file mode 100644
index 0000000..63e41a8
--- /dev/null
+++ b/templates/toast/toast.html.twig
@@ -0,0 +1,21 @@
+{# Toast component (Bootstrap 5) #}
+{% set isLightBg = type in ['warning', 'light'] %}
+
+
+
+
+ {% block content %}
+ {{ text|raw }}
+ {% endblock %}
+
+ {% if dismissible %}
+
+ {% endif %}
+
+
diff --git a/tests/Component/Toast/ToastTest.php b/tests/Component/Toast/ToastTest.php
new file mode 100644
index 0000000..b56c1f9
--- /dev/null
+++ b/tests/Component/Toast/ToastTest.php
@@ -0,0 +1,130 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Enabel\Ux\Tests\Component\Toast;
+
+use Enabel\Ux\Component\Toast\Toast;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
+
+class ToastTest extends TestCase
+{
+ public function testComponentCanBeInstantiatedWithDefaultParameters(): void
+ {
+ $component = new Toast();
+ $data = $component->preMount([]);
+
+ $component->text = $data['text'];
+ $component->type = $data['type'];
+ $component->dismissible = $data['dismissible'];
+ $component->autohide = $data['autohide'];
+ $component->delay = $data['delay'];
+ $component->politeness = $data['politeness'];
+
+ $this->assertNull($component->text);
+ $this->assertSame('info', $component->type);
+ $this->assertTrue($component->dismissible);
+ $this->assertTrue($component->autohide);
+ $this->assertSame(5000, $component->delay);
+ $this->assertSame('polite', $component->politeness);
+ }
+
+ public function testComponentCanBeInstantiatedWithCustomParameters(): void
+ {
+ $component = new Toast();
+ $data = $component->preMount([
+ 'text' => 'Saved successfully',
+ 'type' => 'success',
+ 'dismissible' => false,
+ 'autohide' => false,
+ 'delay' => 10000,
+ 'politeness' => 'assertive',
+ ]);
+
+ $component->text = $data['text'];
+ $component->type = $data['type'];
+ $component->dismissible = $data['dismissible'];
+ $component->autohide = $data['autohide'];
+ $component->delay = $data['delay'];
+ $component->politeness = $data['politeness'];
+
+ $this->assertSame('Saved successfully', $component->text);
+ $this->assertSame('success', $component->type);
+ $this->assertFalse($component->dismissible);
+ $this->assertFalse($component->autohide);
+ $this->assertSame(10000, $component->delay);
+ $this->assertSame('assertive', $component->politeness);
+ }
+
+ public function testInvalidTypeThrows(): void
+ {
+ $this->expectException(InvalidOptionsException::class);
+
+ $component = new Toast();
+ $component->preMount(['type' => 'unknown']);
+ }
+
+ public function testValidTypes(): void
+ {
+ $component = new Toast();
+ foreach (['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] as $type) {
+ $data = $component->preMount(['type' => $type]);
+ $this->assertSame($type, $data['type']);
+ }
+ }
+
+ public function testInvalidPolitenessThrows(): void
+ {
+ $this->expectException(InvalidOptionsException::class);
+
+ $component = new Toast();
+ $component->preMount(['politeness' => 'rude']);
+ }
+
+ public function testInvalidTextTypeThrows(): void
+ {
+ $this->expectException(InvalidOptionsException::class);
+
+ $component = new Toast();
+ $component->preMount(['text' => 123]);
+ }
+
+ public function testInvalidDismissibleTypeThrows(): void
+ {
+ $this->expectException(InvalidOptionsException::class);
+
+ $component = new Toast();
+ $component->preMount(['dismissible' => 'yes']);
+ }
+
+ public function testInvalidAutohideTypeThrows(): void
+ {
+ $this->expectException(InvalidOptionsException::class);
+
+ $component = new Toast();
+ $component->preMount(['autohide' => 'yes']);
+ }
+
+ public function testInvalidDelayTypeThrows(): void
+ {
+ $this->expectException(InvalidOptionsException::class);
+
+ $component = new Toast();
+ $component->preMount(['delay' => '5000']);
+ }
+
+ public function testPreMountPreservesAdditionalData(): void
+ {
+ $component = new Toast();
+ $data = $component->preMount(['text' => 'Hi', 'custom_attribute' => 'value']);
+
+ $this->assertArrayHasKey('custom_attribute', $data);
+ $this->assertSame('value', $data['custom_attribute']);
+ }
+}