aotr / open-a11y-laravel
Open-source accessibility widget for Laravel Blade, Livewire, and Inertia React, Vue, and Svelte. WCAG-focused, MIT, self-hosted.
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. The current bundle is open-a11y 2.2.0,
including Underline Links and separate mobile button sizing. If your app already
bundles the npm core, point open-a11y.assets.url at your built asset (or
publish and edit the package views).
Common questions
Is this an accessibility overlay? No. It is a client-side preference toolbar. It does not claim to remediate inaccessible markup or make a site legally compliant.
Does it require Node or Vite? No for Blade and Livewire. The package ships a standalone browser bundle. The Inertia examples use Vite, but the published component does not require it.
Does it support Livewire 4? Yes. The package supports Livewire 3 and 4. The current release is tested against both.
Does it make my Laravel app WCAG compliant? No. It adds optional user controls. You still need semantic Blade markup, keyboard testing, screen-reader testing, and manual audits.
Is it free? Yes. The package is MIT licensed. There is no per-site subscription and no vendor script.
Where are preferences stored?
The browser runtime stores them in localStorage by default. Server-side
preference sync is not included.
Blade
<x-open-a11y::widget :config="[ 'position' => 'bottom-right', 'side' => 'right', 'offset' => [24, 24], 'blur' => true, 'buttonSize' => 56, 'buttonSizeMobile' => 44, '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 |
buttonSizeMobile |
buttonSizeMobile |
int (px) |
44 |
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, 'button_size_mobile' => 44, '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.



