Standalone example · v1
Lighthouse and axe: the same product page.
A desk lamp with deliberately isolated barriers. Compare rule selection, keyboard operation and an opened dialog in three separate cases.
No login, cart or consent step is needed. Prepare clears only this example’s temporary action and dialog. For a repeatable scan, reset display tests to 100% text with spacing and preview off.
Reference versions: Lighthouse 13.5.0, axe-core 4.13.0. Record the versions in your own reports.
Tab from the case links to the add action. In the barriers variant, the clickable div is skipped. The improved native button works with Enter and Space. A clean scan cannot confirm that you can complete this action.

Demo product · no purchase
Desk lamp
€49.00
Olive-green metal, a rounded shade and a warm pool of light for your desk. This product is fictional.
No lamp added in this example.
Continue to the source codeView the shared source
These are the actual source files used by this fixture. A generated snapshot is checked against the implementation before each build validation. Imports and reusable helpers remain visible; this is not a separate teaching copy.
src/components/features/examples/lighthouse-product.tsx
'use client';
import Image from 'next/image';
import Link from 'next/link';
import {useRef,useState} from 'react';
import {useTranslations} from 'next-intl';
import {Button} from '@/components/ui/button';
import {Dialog,DialogPortal,DialogOverlay,DialogContent,DialogTitle,DialogDescription} from '@/components/ui/dialog';
import {asset} from '@/lib/fixtures/assets';
import {formatPrice} from '@/lib/format';
import {lighthouseExamplePath,type DemoRoute,type LighthouseCase} from '@/lib/routes';
import {lighthouseVersions} from '@/lib/examples/lighthouse';
export function LighthouseProduct({route,testCase}:{route:DemoRoute;testCase:LighthouseCase}){
const t=useTranslations('Lighthouse');
const assets=useTranslations('Assets');
const improved=route.variant==='improved';
const photo=asset('desk-lamp');
const [added,setAdded]=useState(false);
const [open,setOpen]=useState(false);
const trigger=useRef<HTMLButtonElement>(null);
const care=useRef<HTMLDetailsElement>(null);
function showCare(){if(care.current)care.current.open=true;}
return <section className="lighthouse-product" aria-labelledby="lh-product-title">
<nav className="lighthouse-case-nav" aria-label={t('cases')}>
{(['rules','keyboard','dialog'] as const).map(value=><Link key={value} href={lighthouseExamplePath(route,value)} aria-current={testCase===value?'page':undefined}>{t(`${value}.label`)}</Link>)}
</nav>
<p className="field-help">{t('versions',{...lighthouseVersions})}</p>
<p id="lh-case-instructions">{t(`${testCase}.instructions`)}</p>
<div className="product-detail-grid">
<div className="product-gallery"><Image src={photo.file} width={photo.width} height={photo.height} alt={assets('desk-lamp.alt')} sizes="(max-width: 760px) 100vw, 55vw" priority/></div>
<div className="product-information">
<p className="eyebrow">{t('edition')}</p><h2 id="lh-product-title">{t('product')}</h2>
<p className="product-detail-price">{formatPrice(4900,route.locale)}</p><p>{t('description')}</p>
{testCase==='rules'&&<>
<h3>{t('lightOutput')}</h3>
{/* Intentional role-img-alt: a meaningful chart has no name only in barriers. */}
<div id="lh-chart" role="img" aria-label={improved?t('chartAlternative'):undefined} className="lighthouse-chart"/>
<p className="field-help">{t('chartCaption')}</p>
<h3>{t('delivery')}</h3>
{/* Explicit -1 prevents Chromium's automatic scroll-container tab stop in barriers. */}
<div id="lh-scroll" className="lighthouse-scroll" tabIndex={improved?0:-1}>{[1,2,3].map(number=><p key={number}>{t(`delivery${number}`)}</p>)}</div>
{/* Intentional nested-interactive; shared Button remains unchanged. */}
<div id="lh-nested" className="lighthouse-nested" role={improved?undefined:'button'} tabIndex={improved?undefined:0} onClick={improved?undefined:showCare} onKeyDown={improved?undefined:event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();showCare();}}}><a href="#lh-details" onClick={showCare}>{t('detailsLink')}</a></div>
{/* Intentional avoid-inline-spacing; !important is applied after hydration. */}
<p id="lh-spacing" ref={node=>{if(node&&!improved)node.style.setProperty('letter-spacing','0','important');}}>{t('spacing')}</p>
<details ref={care} id="lh-details"><summary id="lh-summary" className="lighthouse-summary">{improved?t('care'):null}</summary><p>{t('careText')}</p></details>
<div role="tablist" aria-label={t('information')}><div id="lh-tab" className="lighthouse-tab" role="tab" aria-selected="true" aria-controls="lh-panel" tabIndex={0}>{improved?t('specifications'):null}</div></div>
<div id="lh-panel" role="tabpanel" aria-label={t('specifications')}><p>{t('specificationText')}</p></div>
</>}
{testCase==='keyboard'&&<>
{/* Intentional manual case: no role, tab stop or keyboard handler on this div. */}
{improved?<Button id="lh-cart" type="button" onClick={()=>setAdded(true)}>{t('add')}</Button>:<div id="lh-cart" className="button button-primary button-default" onClick={()=>setAdded(true)}>{t('add')}</div>}
<p id="lh-status" role="status" className="sample-result">{added?t('added'):t('empty')}</p>
<a id="lh-after-action" href="#example-source">{t('afterAction')}</a>
</>}
{testCase==='dialog'&&<>
<Button ref={trigger} id="lh-open" type="button" onClick={()=>setOpen(true)}>{t('open')}</Button>
<Dialog open={open} onOpenChange={setOpen}><DialogPortal><DialogOverlay className="dialog-overlay"/><DialogContent className="cookie-dialog" id="lh-dialog" onCloseAutoFocus={event=>{event.preventDefault();trigger.current?.focus();}}><DialogTitle>{t('dialogTitle')}</DialogTitle><DialogDescription>{t('dialogDescription')}</DialogDescription>
{/* The dialog, focus trap and Escape recovery are correct in both variants. */}
<Button id="lh-close" type="button" variant="outline" className="lighthouse-dialog-close" aria-label={improved?t('close'):undefined} onClick={()=>setOpen(false)}><svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" strokeWidth="2"/></svg></Button>
</DialogContent></DialogPortal></Dialog>
</>}
</div>
</div>
</section>;
}
src/lib/examples/lighthouse.ts
// These are intended rule candidates, not recorded scanner findings.
export const lighthouseRuleCases=[
{id:'lighthouse-role-image',targetId:'lh-chart',rule:'role-img-alt',sc:'1.1.1'},
{id:'lighthouse-scroll-region',targetId:'lh-scroll',rule:'scrollable-region-focusable',sc:'2.1.1'},
{id:'lighthouse-nested-control',targetId:'lh-nested',rule:'nested-interactive',sc:'4.1.2'},
{id:'lighthouse-text-spacing',targetId:'lh-spacing',rule:'avoid-inline-spacing',sc:'1.4.12'},
{id:'lighthouse-summary-name',targetId:'lh-summary',rule:'summary-name',sc:'4.1.2'},
{id:'lighthouse-tab-name',targetId:'lh-tab',rule:'aria-tab-name',sc:'4.1.2'}
] as const;
export const lighthouseVersions={lighthouse:'13.5.0',axe:'4.13.0'} as const;
src/app/[locale]/globals.css
.lighthouse-product {min-width:0;}
.lighthouse-scroll {height:6rem;overflow:auto;border:1px solid var(--border);padding:.75rem;margin-bottom:1rem;}
.lighthouse-chart {display:flex;gap:1rem;align-items:end;height:5rem;margin-bottom:.5rem;}
.lighthouse-chart::before {content:'';display:block;width:40%;height:60%;background:var(--primary);}
.lighthouse-chart::after {content:'';display:block;width:40%;height:100%;background:var(--primary);}
.lighthouse-summary {min-height:44px;padding:.5rem 0;cursor:pointer;}
.lighthouse-tab {display:inline-flex;align-items:center;min-height:44px;min-width:100px;background:var(--card);color:var(--foreground);border:1px solid var(--border);padding:.5rem 1rem;cursor:pointer;}
.lighthouse-nested {padding:.5rem 0;}.lighthouse-nested>a {display:inline-flex;min-height:44px;align-items:center;}
.lighthouse-case-nav {display:flex;flex-wrap:wrap;gap:1rem;margin-bottom:1rem;}
.lighthouse-dialog-close {min-width:48px;}