aotr / open-a11y-laravel
The open-a11y accessibility widget for Laravel Blade, Livewire, and Inertia React/Vue/Svelte.
Requires
- php: ^8.2
- illuminate/support: ^12.0 || ^13.0
- illuminate/view: ^12.0 || ^13.0
- livewire/livewire: ^3.5 || ^4.0
Requires (Dev)
- inertiajs/inertia-laravel: ^3.5
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
An accessibility widget with a real engine behind it — made native to Laravel.
open-a11y ships profiles, individual tools, keyboard shortcuts, persisted preferences, and a shadow-DOM interface. This package gives that runtime a Blade component, a Livewire component, publishable config, and a standalone browser bundle. It also includes complete Inertia React, Vue, and Svelte example pages. No Node, Vite, or front-end build is required for the Blade/Livewire package itself.
<x-open-a11y::widget :config="['position' => 'bottom-right', 'brand' => ['primary' => '#0f6b4f']]" />
Requirements
- PHP 8.2+
- Laravel 12 or 13
- Livewire 3 or 4 (for the Livewire component)
- Optional: Inertia 3 with React 19 or Vue 3 (for the Inertia examples)
- Node.js + pnpm only for rebuilding the Inertia demo assets
- A browser with Web Components and Shadow DOM: Chrome 90+, Firefox 90+, Safari 15+, Edge 90+
The Blade component is independent of Livewire. The package requires Livewire because it ships both integrations.
Installation
composer require aotr/open-a11y-laravel
Laravel package discovery registers Aotr\OpenA11y\OpenA11yServiceProvider automatically.
Publish the browser bundle:
php artisan vendor:publish --tag=open-a11y-assets
This copies the runtime to public/vendor/open-a11y/. The Blade and Livewire
components inject the <script> tags once per page, so you do not need to add
them to your layout.
Optionally publish the config file:
php artisan vendor:publish --tag=open-a11y-config
Set OPEN_A11Y_ASSET_URL (or open-a11y.assets.url) when serving the bundle
from a CDN. The published public/vendor/open-a11y directory must be web
accessible when using the default configuration.
About the JavaScript core
The framework-agnostic runtime is available on npm as
@aotr/open-a11y:
npm install @aotr/open-a11y
This package ships the standalone IIFE browser bundle in
resources/dist/open-a11y.js, so Laravel applications can use the widget
without a JavaScript toolchain. If your app already bundles the npm core, point
open-a11y.assets.url at your built asset (or publish and edit the package
views).
Blade
<x-open-a11y::widget :config="[ 'position' => 'bottom-right', 'side' => 'right', 'offset' => [24, 24], 'blur' => true, 'buttonSize' => 56, 'showButton' => true, 'lang' => 'en', 'brand' => [ 'name' => 'Acme', 'url' => 'https://acme.example', 'footerText' => 'Accessibility by Acme', 'primary' => '#0f6b4f', 'primaryDark' => '#0a4a37', 'accent' => '#d4a13a', ], 'labels' => [ 'widget.menuTitle' => 'Accessibility options', ], ]" />
The component merges your array over the package defaults in
config/open-a11y.php, renders a hidden mount element with the JSON config,
and includes the two browser scripts once.
Pass a stable widget-key when you want to target the instance from your own
JavaScript:
<x-open-a11y::widget :config="$config" widget-key="site-widget" />
Livewire
<livewire:open-a11y::widget position="bottom-right" side="right" :offset-x="24" :offset-y="24" :blur="true" :button-size="56" :show-button="true" primary="#0f6b4f" primary-dark="#0a4a37" accent="#d4a13a" footer-name="Acme" footer-url="https://acme.example" footer-text="Accessibility by Acme" menu-title="Accessibility options" language="en" />
Every property is reactive. When a property changes, the component dispatches a browser event with the new config:
window.addEventListener("open-a11y:update", (event) => { // event.detail = { key: 'open-a11y-…', config: { … } } });
The package's auto-init script listens for that event and remounts the widget.
The underlying engine is shared across mounts, so the visitor's selected tools,
contrast modes, and language stay in localStorage — a color change does not
reset their preferences.
Bind properties from a parent Livewire component or update them on the component itself; both paths flow through the same event.
Inertia + React
The repository includes a full Inertia React page at /inertia-react.
The package itself is framework-neutral; use the official React adapter in your Inertia entry:
npm install @aotr/open-a11y @inertiajs/react react react-dom
// resources/js/app.tsx import { createInertiaApp } from "@inertiajs/react"; import { createRoot } from "react-dom/client"; import { AccessibilityEngine, localStorageAdapter } from "@aotr/open-a11y"; import { AccessibilityProvider } from "@aotr/open-a11y/react"; const engine = new AccessibilityEngine({ storage: localStorageAdapter }); createInertiaApp({ resolve: (name) => import(`./pages/${name}.tsx`), setup({ el, App, props }) { createRoot(el).render( <AccessibilityProvider engine={engine} autoInit> <App {...props} /> </AccessibilityProvider>, ); }, });
// resources/js/pages/Welcome.tsx import { AccessibilityWidget } from "@aotr/open-a11y/react"; export default function Welcome({ config }) { return ( <> <h1>Your Inertia page</h1> <AccessibilityWidget config={config} /> </> ); }
Inertia + Vue
npm install @aotr/open-a11y @inertiajs/vue3 vue
// resources/js/app.ts import { createApp, h } from "vue"; import { createInertiaApp } from "@inertiajs/vue3"; import { AccessibilityEngine, localStorageAdapter } from "@aotr/open-a11y"; import { createAccessibilityPlugin } from "@aotr/open-a11y/vue"; const engine = new AccessibilityEngine({ storage: localStorageAdapter }); createInertiaApp({ resolve: (name) => import(`./pages/${name}.vue`), setup({ el, App, props, plugin }) { createApp({ render: () => h(App, props) }) .use(plugin) .use(createAccessibilityPlugin(engine)) .mount(el); }, });
<script setup lang="ts"> import { A11yWidget } from "@aotr/open-a11y/vue"; defineProps<{ config: Record<string, unknown> }>(); </script> <template> <h1>Your Inertia page</h1> <A11yWidget :options="{ config }" /> </template>
Inertia + Svelte
npm install @aotr/open-a11y @inertiajs/svelte svelte
// resources/js/app.ts import { createInertiaApp } from "@inertiajs/svelte"; import { mount } from "svelte"; import { AccessibilityEngine, localStorageAdapter } from "@aotr/open-a11y"; const engine = new AccessibilityEngine({ storage: localStorageAdapter }); void engine.init().then(() => createInertiaApp({ resolve: (name) => import(`./pages/${name}.svelte`), setup({ el, App, props }) { mount(App, { target: el, props }); }, }), );
<script lang="ts"> import { accessibilityWidget } from "@aotr/open-a11y/svelte"; import { engine } from "./accessibility-engine"; let { config } = $props(); const widgetOptions = { engine, config }; </script> <h1>Your Inertia page</h1> <div use:accessibilityWidget={widgetOptions}></div>
All three Inertia examples use the same server-resolved config as Blade and
Livewire. The demo pages are in
workbench/resources/js/pages.
Customization
Blade passes the runtime config keys directly. Livewire exposes the most useful knobs as typed public properties.
| Livewire property | Blade config key | Type | Default |
|---|---|---|---|
position |
position |
bottom-right, bottom-left, top-right, top-left |
bottom-right |
side |
side |
right, left |
right |
offsetX, offsetY |
offset |
[int, int] |
[20, 20] |
blur |
blur |
bool |
false |
buttonSize |
buttonSize |
int (px) |
56 |
showButton |
showButton |
bool |
true |
primary |
brand.primary |
CSS color | #0f6b4f |
primaryDark |
brand.primaryDark |
CSS color | #0a4a37 |
accent |
brand.accent |
CSS color | #d4a13a |
footerName |
brand.name |
string |
'' |
footerUrl |
brand.url |
string |
'' |
footerText |
brand.footerText |
string |
'' |
menuTitle |
labels.widget.menuTitle |
string |
Accessibility options |
language |
lang |
en, hi |
en |
widgetKey |
widget-key attribute |
string |
generated |
An empty footerText hides the footer. When it is omitted and brand.name is
set, the runtime falls back to Accessibility by {name}.
Package-level defaults live in config/open-a11y.php:
return [ 'position' => 'bottom-right', 'side' => 'right', 'offset' => [20, 20], 'blur' => false, 'button_size' => 56, 'show_button' => true, 'language' => 'en', 'menu_title' => 'Accessibility options', 'brand' => [ 'name' => null, 'url' => null, 'footer_text' => null, 'primary' => '#0f6b4f', 'primary_dark' => '#0a4a37', 'accent' => '#d4a13a', ], 'assets' => [ 'path' => 'vendor/open-a11y', 'url' => null, // e.g. https://cdn.example.com/a11y ], ];
Need a custom UI string that is not exposed as a Livewire property? Use the
Blade component and pass it through labels:
<x-open-a11y::widget :config="[ 'labels' => [ 'widget.menuTitle' => 'Reading options', 'widget.reset' => 'Clear all settings', ], ]" />
Demo
The repository includes a Testbench workbench with four demos:
/blade— Blade component with a plain-JavaScript customizer/livewire— Livewire component with server-reactive controls/inertia-react— Inertia page powered by React 19/inertia-vue— Inertia page powered by Vue 3/inertia-svelte— Inertia page powered by Svelte 5
composer install pnpm install composer demo
composer demo builds the Inertia entries and starts the workbench on
http://127.0.0.1:4179. The Blade and Livewire assets are served from
resources/dist; the Inertia entries are built with Vite+ and served from the
Testbench public directory.
Open any of the four routes from the workbench navigation. The Inertia
components live in
workbench/resources/js/pages.
Testing
composer test
The suite uses Orchestra Testbench and PHPUnit. It covers:
- Blade component rendering, JSON config serialization, and attribute merging
- Livewire initial props, browser event dispatch, and language updates
- Service provider view registration and publish tags
- All five demo routes and the asset routes
- Inertia React, Vue, and Svelte page components and server props
- Config defaults and override precedence
Publishing
| Tag | Copies |
|---|---|
open-a11y-config |
config/open-a11y.php → config/open-a11y.php |
open-a11y-views |
resources/views → resources/views/vendor/open-a11y |
open-a11y-assets |
resources/dist → public/vendor/open-a11y |
open-a11y |
all of the above |
php artisan vendor:publish --tag=open-a11y
Use --force to overwrite previously published files.
How it fits together
<x-open-a11y::widget> <livewire:open-a11y::widget>
│ │
└──────────────┬───────────────────┘
▼
hidden [data-open-a11y] element
with JSON config attribute
│
▼
resources/dist/open-a11y-laravel.js
reads config · owns shared engine
persists to localStorage · remounts
│
▼
resources/dist/open-a11y.js (IIFE)
web components · shadow DOM · tools
The widget's shadow DOM is intentionally left alone by the package and the demo. Brand colors and copy are passed through the runtime config; the host page keeps control of its own typography, spacing, and layout.
The upstream runtime maintains one active floating widget per page. Render one
open-a11y component per page and use widget-key to target it from custom
JavaScript.
Contributing
Bug reports and pull requests are welcome. See CONTRIBUTING.md for the local setup, the test command, and the review checklist.
License
open-a11y Laravel is open-source software licensed under the MIT license.



