[{"data":1,"prerenderedAt":826},["ShallowReactive",2],{"layout-sidebar-\u002Fblog\u002Fuse-announcer-nuxt":3,"blog-use-announcer-nuxt":32},{"type":4,"author":5,"date":6,"status":7,"toc":8},"blog-detail","Fabian Kirchhoff","2026-05-15","published",[9,13,16,20,23,26,29],{"id":10,"text":11,"depth":12},"the-problem","The Problem",2,{"id":14,"text":15,"depth":12},"how-aria-live-works","How aria-live Works",{"id":17,"text":18,"depth":19},"the-empty-then-fill-trick","The Empty-Then-Fill Trick",3,{"id":21,"text":22,"depth":12},"common-use-cases","Common Use Cases",{"id":24,"text":25,"depth":12},"the-announcer-pattern","The Announcer Pattern",{"id":27,"text":28,"depth":12},"when-not-to-use-this","When Not to Use This",{"id":30,"text":31,"depth":12},"resources","Resources",{"id":33,"title":34,"author":5,"body":35,"date":6,"description":814,"extension":815,"featured":372,"meta":816,"navigation":372,"order":12,"path":817,"seo":818,"specs":819,"status":7,"stem":823,"tag":824,"__hash__":825},"blog\u002Fblog\u002Fuse-announcer-nuxt.md","Announcing Dynamic Changes in Vue with aria-live",{"type":36,"value":37,"toc":804},"minimark",[38,42,46,53,56,59,62,65,67,73,132,135,164,170,173,176,256,259,261,267,273,279,285,291,303,305,311,317,559,566,708,710,713,723,741,747,756,758,800],[39,40,34],"h1",{"id":41},"announcing-dynamic-changes-in-vue-with-aria-live",[43,44,45],"p",{},"A Vue app re-renders reactively — a ref changes, the DOM updates, the user sees it. Screen readers don't. They need to be told explicitly when something important changed.",[43,47,48,52],{},[49,50,51],"code",{},"aria-live"," is the attribute that does this. It tells the browser: \"When this element's content changes, announce it.\"",[54,55,11],"h2",{"id":10},[43,57,58],{},"A screen reader builds a virtual representation of the page. It announces content when the user navigates to it — Tab to a button, arrow through a list, read the next paragraph. But it doesn't re-read the entire page when something changes. Why would it? On a static page, nothing changes unless the user interacts.",[43,60,61],{},"SPAs break this assumption. Route changes swap the entire view. Form submissions show success messages. Search inputs filter a list in real-time. Toast notifications pop up and disappear. None of these trigger a screen reader announcement by default.",[43,63,64],{},"The user submits a form, hears nothing, and has no idea whether it worked.",[54,66,15],{"id":14},[43,68,69,70,72],{},"An ",[49,71,51],{}," region is an element the browser watches for content mutations. When text inside it changes, the screen reader announces the new content — even if the user's focus is somewhere else entirely.",[74,75,80],"pre",{"className":76,"code":77,"language":78,"meta":79,"style":79},"language-html shiki shiki-themes github-dark github-dark","\u003Cdiv aria-live=\"polite\" aria-atomic=\"true\">\n  \u003C!-- When text changes here, screen readers announce it -->\n\u003C\u002Fdiv>\n","html","",[49,81,82,117,123],{"__ignoreMap":79},[83,84,87,91,95,99,102,106,109,111,114],"span",{"class":85,"line":86},"line",1,[83,88,90],{"class":89},"suv1-","\u003C",[83,92,94],{"class":93},"sxg3X","div",[83,96,98],{"class":97},"sFR8T"," aria-live",[83,100,101],{"class":89},"=",[83,103,105],{"class":104},"s4wv1","\"polite\"",[83,107,108],{"class":97}," aria-atomic",[83,110,101],{"class":89},[83,112,113],{"class":104},"\"true\"",[83,115,116],{"class":89},">\n",[83,118,119],{"class":85,"line":12},[83,120,122],{"class":121},"sJ8bj","  \u003C!-- When text changes here, screen readers announce it -->\n",[83,124,125,128,130],{"class":85,"line":19},[83,126,127],{"class":89},"\u003C\u002F",[83,129,94],{"class":93},[83,131,116],{"class":89},[43,133,134],{},"Three politeness levels:",[136,137,138,148,156],"ul",{},[139,140,141,147],"li",{},[142,143,144],"strong",{},[49,145,146],{},"polite"," — waits until the screen reader finishes its current announcement, then speaks. Use this for most things: status updates, search results counts, non-critical feedback.",[139,149,150,155],{},[142,151,152],{},[49,153,154],{},"assertive"," — interrupts whatever the screen reader is currently saying. Reserved for errors and time-sensitive information.",[139,157,158,163],{},[142,159,160],{},[49,161,162],{},"off"," — the default. Changes are not announced.",[43,165,166,169],{},[49,167,168],{},"aria-atomic=\"true\""," tells the screen reader to announce the entire region content, not just the changed text node. Without it, partial updates can produce confusing fragments.",[171,172,18],"h3",{"id":17},[43,174,175],{},"There's a browser quirk: if you set the same text twice in a row, some screen readers won't announce it the second time (the content didn't \"change\"). The workaround is to clear the region first, then set the new text on the next tick:",[74,177,181],{"className":178,"code":179,"language":180,"meta":79,"style":79},"language-typescript shiki shiki-themes github-dark github-dark","function announce(message: string) {\n  region.textContent = ''\n  requestAnimationFrame(() => {\n    region.textContent = message\n  })\n}\n","typescript",[49,182,183,209,219,233,244,250],{"__ignoreMap":79},[83,184,185,189,192,195,199,202,206],{"class":85,"line":86},[83,186,188],{"class":187},"sOPea","function",[83,190,191],{"class":97}," announce",[83,193,194],{"class":89},"(",[83,196,198],{"class":197},"s-3mD","message",[83,200,201],{"class":187},":",[83,203,205],{"class":204},"s8ozJ"," string",[83,207,208],{"class":89},") {\n",[83,210,211,214,216],{"class":85,"line":12},[83,212,213],{"class":89},"  region.textContent ",[83,215,101],{"class":187},[83,217,218],{"class":104}," ''\n",[83,220,221,224,227,230],{"class":85,"line":19},[83,222,223],{"class":97},"  requestAnimationFrame",[83,225,226],{"class":89},"(() ",[83,228,229],{"class":187},"=>",[83,231,232],{"class":89}," {\n",[83,234,236,239,241],{"class":85,"line":235},4,[83,237,238],{"class":89},"    region.textContent ",[83,240,101],{"class":187},[83,242,243],{"class":89}," message\n",[83,245,247],{"class":85,"line":246},5,[83,248,249],{"class":89},"  })\n",[83,251,253],{"class":85,"line":252},6,[83,254,255],{"class":89},"}\n",[43,257,258],{},"This forces a mutation the browser recognizes as a change, guaranteeing the announcement fires.",[54,260,22],{"id":21},[43,262,263,266],{},[142,264,265],{},"Route changes"," — SPAs don't trigger the screen reader's \"new page loaded\" announcement. A live region can announce the new page title after navigation.",[43,268,269,272],{},[142,270,271],{},"Form feedback"," — \"Message sent\", \"3 validation errors\", \"Saving...\". The user needs confirmation that their action had an effect.",[43,274,275,278],{},[142,276,277],{},"Live search results"," — \"Found 12 results\" as the user types. Without this, filtering a list is completely silent.",[43,280,281,284],{},[142,282,283],{},"Async operations"," — \"Loading\", then \"Data loaded\" or \"Request failed\". Without these, the user has no idea the app is working.",[43,286,287,290],{},[142,288,289],{},"Toast notifications"," — these are visually obvious but completely invisible to screen readers without a live region backing them.",[292,293,294],"blockquote",{},[43,295,296,299,300,302],{},[142,297,298],{},"Don't overuse this."," Most dynamic UI in Vue is better served by moving focus to the new content or using semantic HTML. ",[49,301,51],{}," is for the cases where there's no element to focus — a background process finishing, a count changing while the user types elsewhere. Reach for focus management first.",[54,304,25],{"id":24},[43,306,307,308,310],{},"Scattering ",[49,309,51],{}," regions throughout the app is messy. The announcer pattern centralizes this: one hidden live region in the DOM, one composable to control it from anywhere.",[43,312,313,314,316],{},"The region component renders a visually hidden ",[49,315,51],{}," element. The composable controls it:",[74,318,320],{"className":178,"code":319,"language":180,"meta":79,"style":79},"const message = shallowRef('')\nconst politeness = shallowRef\u003CPoliteness>('polite')\n\nexport function useAnnouncer() {\n  function set(msg: string, level: Politeness = 'polite') {\n    message.value = ''\n    nextTick(() => {\n      politeness.value = level\n      message.value = msg\n    })\n  }\n\n  function polite(msg: string) { set(msg, 'polite') }\n  function assertive(msg: string) { set(msg, 'assertive') }\n\n  return { message, politeness, set, polite, assertive }\n}\n",[49,321,322,344,368,374,388,423,432,444,455,466,472,478,483,513,540,545,554],{"__ignoreMap":79},[83,323,324,327,330,333,336,338,341],{"class":85,"line":86},[83,325,326],{"class":187},"const",[83,328,329],{"class":204}," message",[83,331,332],{"class":187}," =",[83,334,335],{"class":97}," shallowRef",[83,337,194],{"class":89},[83,339,340],{"class":104},"''",[83,342,343],{"class":89},")\n",[83,345,346,348,351,353,355,357,360,363,366],{"class":85,"line":12},[83,347,326],{"class":187},[83,349,350],{"class":204}," politeness",[83,352,332],{"class":187},[83,354,335],{"class":97},[83,356,90],{"class":89},[83,358,359],{"class":97},"Politeness",[83,361,362],{"class":89},">(",[83,364,365],{"class":104},"'polite'",[83,367,343],{"class":89},[83,369,370],{"class":85,"line":19},[83,371,373],{"emptyLinePlaceholder":372},true,"\n",[83,375,376,379,382,385],{"class":85,"line":235},[83,377,378],{"class":187},"export",[83,380,381],{"class":187}," function",[83,383,384],{"class":97}," useAnnouncer",[83,386,387],{"class":89},"() {\n",[83,389,390,393,396,398,401,403,405,408,411,413,416,418,421],{"class":85,"line":246},[83,391,392],{"class":187},"  function",[83,394,395],{"class":97}," set",[83,397,194],{"class":89},[83,399,400],{"class":197},"msg",[83,402,201],{"class":187},[83,404,205],{"class":204},[83,406,407],{"class":89},", ",[83,409,410],{"class":197},"level",[83,412,201],{"class":187},[83,414,415],{"class":97}," Politeness",[83,417,332],{"class":187},[83,419,420],{"class":104}," 'polite'",[83,422,208],{"class":89},[83,424,425,428,430],{"class":85,"line":252},[83,426,427],{"class":89},"    message.value ",[83,429,101],{"class":187},[83,431,218],{"class":104},[83,433,435,438,440,442],{"class":85,"line":434},7,[83,436,437],{"class":97},"    nextTick",[83,439,226],{"class":89},[83,441,229],{"class":187},[83,443,232],{"class":89},[83,445,447,450,452],{"class":85,"line":446},8,[83,448,449],{"class":89},"      politeness.value ",[83,451,101],{"class":187},[83,453,454],{"class":89}," level\n",[83,456,458,461,463],{"class":85,"line":457},9,[83,459,460],{"class":89},"      message.value ",[83,462,101],{"class":187},[83,464,465],{"class":89}," msg\n",[83,467,469],{"class":85,"line":468},10,[83,470,471],{"class":89},"    })\n",[83,473,475],{"class":85,"line":474},11,[83,476,477],{"class":89},"  }\n",[83,479,481],{"class":85,"line":480},12,[83,482,373],{"emptyLinePlaceholder":372},[83,484,486,488,491,493,495,497,499,502,505,508,510],{"class":85,"line":485},13,[83,487,392],{"class":187},[83,489,490],{"class":97}," polite",[83,492,194],{"class":89},[83,494,400],{"class":197},[83,496,201],{"class":187},[83,498,205],{"class":204},[83,500,501],{"class":89},") { ",[83,503,504],{"class":97},"set",[83,506,507],{"class":89},"(msg, ",[83,509,365],{"class":104},[83,511,512],{"class":89},") }\n",[83,514,516,518,521,523,525,527,529,531,533,535,538],{"class":85,"line":515},14,[83,517,392],{"class":187},[83,519,520],{"class":97}," assertive",[83,522,194],{"class":89},[83,524,400],{"class":197},[83,526,201],{"class":187},[83,528,205],{"class":204},[83,530,501],{"class":89},[83,532,504],{"class":97},[83,534,507],{"class":89},[83,536,537],{"class":104},"'assertive'",[83,539,512],{"class":89},[83,541,543],{"class":85,"line":542},15,[83,544,373],{"emptyLinePlaceholder":372},[83,546,548,551],{"class":85,"line":547},16,[83,549,550],{"class":187},"  return",[83,552,553],{"class":89}," { message, politeness, set, polite, assertive }\n",[83,555,557],{"class":85,"line":556},17,[83,558,255],{"class":89},[43,560,561,562,565],{},"Module-level refs make this a singleton — every call to ",[49,563,564],{},"useAnnouncer()"," shares the same live region.",[74,567,571],{"className":568,"code":569,"language":570,"meta":79,"style":79},"language-vue shiki shiki-themes github-dark github-dark","\u003Cscript setup lang=\"ts\">\nconst { polite, assertive } = useAnnouncer()\n\nasync function submitForm() {\n  await $fetch('\u002Fapi\u002Fcontact', { method: 'POST', body: formData })\n    .then(() => polite('Message sent'))\n    .catch(() => assertive('Error: Failed to send message'))\n}\n\u003C\u002Fscript>\n","vue",[49,572,573,593,616,620,632,654,676,696,700],{"__ignoreMap":79},[83,574,575,577,580,583,586,588,591],{"class":85,"line":86},[83,576,90],{"class":89},[83,578,579],{"class":93},"script",[83,581,582],{"class":97}," setup",[83,584,585],{"class":97}," lang",[83,587,101],{"class":89},[83,589,590],{"class":104},"\"ts\"",[83,592,116],{"class":89},[83,594,595,597,600,602,604,606,609,611,613],{"class":85,"line":12},[83,596,326],{"class":187},[83,598,599],{"class":89}," { ",[83,601,146],{"class":204},[83,603,407],{"class":89},[83,605,154],{"class":204},[83,607,608],{"class":89}," } ",[83,610,101],{"class":187},[83,612,384],{"class":97},[83,614,615],{"class":89},"()\n",[83,617,618],{"class":85,"line":19},[83,619,373],{"emptyLinePlaceholder":372},[83,621,622,625,627,630],{"class":85,"line":235},[83,623,624],{"class":187},"async",[83,626,381],{"class":187},[83,628,629],{"class":97}," submitForm",[83,631,387],{"class":89},[83,633,634,637,640,642,645,648,651],{"class":85,"line":246},[83,635,636],{"class":187},"  await",[83,638,639],{"class":97}," $fetch",[83,641,194],{"class":89},[83,643,644],{"class":104},"'\u002Fapi\u002Fcontact'",[83,646,647],{"class":89},", { method: ",[83,649,650],{"class":104},"'POST'",[83,652,653],{"class":89},", body: formData })\n",[83,655,656,659,662,664,666,668,670,673],{"class":85,"line":252},[83,657,658],{"class":89},"    .",[83,660,661],{"class":97},"then",[83,663,226],{"class":89},[83,665,229],{"class":187},[83,667,490],{"class":97},[83,669,194],{"class":89},[83,671,672],{"class":104},"'Message sent'",[83,674,675],{"class":89},"))\n",[83,677,678,680,683,685,687,689,691,694],{"class":85,"line":434},[83,679,658],{"class":89},[83,681,682],{"class":97},"catch",[83,684,226],{"class":89},[83,686,229],{"class":187},[83,688,520],{"class":97},[83,690,194],{"class":89},[83,692,693],{"class":104},"'Error: Failed to send message'",[83,695,675],{"class":89},[83,697,698],{"class":85,"line":446},[83,699,255],{"class":89},[83,701,702,704,706],{"class":85,"line":457},[83,703,127],{"class":89},[83,705,579],{"class":93},[83,707,116],{"class":89},[54,709,28],{"id":27},[43,711,712],{},"The announcer is a tool of last resort. Prefer native semantics and focus management first:",[43,714,715,718,719,722],{},[142,716,717],{},"Moving focus is often enough."," After navigating to a new page, focusing the ",[49,720,721],{},"\u003Ch1>"," announces the heading text. After deleting an item from a list, focusing the next item tells the user where they are. Focus is the primary channel screen readers use — an announcement is the fallback.",[43,724,725,728,729,732,733,736,737,740],{},[142,726,727],{},"Native elements already announce."," An ",[49,730,731],{},"\u003Cinput>"," with a visible ",[49,734,735],{},"\u003Clabel>"," doesn't need an announcer to tell the user what field they're in. A ",[49,738,739],{},"\u003Cbutton>"," announces its text content on focus. Don't replicate what the platform gives you for free.",[43,742,743,746],{},[142,744,745],{},"Use the announcer when there's no focus target."," A background save that finishes. A timer that expires. A filter that reduces a list from 50 to 3 items while focus stays in the search input. These have no natural element to focus — that's when the announcer earns its place.",[43,748,749,750,755],{},"For composite widgets where focus management handles navigation directly, see ",[751,752,754],"a",{"href":753},"\u002Fblog\u002Fkeyboard-navigation-composite-widgets","Keyboard Navigation in Composite Widgets",".",[54,757,31],{"id":30},[136,759,760,768,775,793],{},[139,761,762],{},[751,763,767],{"href":764,"rel":765},"https:\u002F\u002Fwww.w3.org\u002FWAI\u002FWCAG22\u002FTechniques\u002Faria\u002FARIA19",[766],"nofollow","ARIA19: Using aria-live regions to identify errors",[139,769,770],{},[751,771,774],{"href":772,"rel":773},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAccessibility\u002FARIA\u002FGuides\u002FLive_regions",[766],"MDN: ARIA live regions",[139,776,777,782,783,782,789],{},[751,778,781],{"href":779,"rel":780},"https:\u002F\u002Fnuxt.com\u002Fdocs\u002Fapi\u002Fcomposables\u002Fuse-announcer",[766],"Nuxt"," ",[751,784,786],{"href":779,"rel":785},[766],[49,787,788],{},"useAnnouncer",[751,790,792],{"href":779,"rel":791},[766],"composable",[139,794,795],{},[751,796,799],{"href":797,"rel":798},"https:\u002F\u002Fgithub.com\u002Fnuxt\u002Fnuxt\u002Fpull\u002F34318",[766],"PR #34318 — feat(nuxt): add useAnnouncer",[801,802,803],"style",{},"html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .sxg3X, html code.shiki .sxg3X{--shiki-default:#85E89D;--shiki-dark:#85E89D}html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .s-3mD, html code.shiki .s-3mD{--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}",{"title":79,"searchDepth":12,"depth":12,"links":805},[806,807,810,811,812,813],{"id":10,"depth":12,"text":11},{"id":14,"depth":12,"text":15,"children":808},[809],{"id":17,"depth":19,"text":18},{"id":21,"depth":12,"text":22},{"id":24,"depth":12,"text":25},{"id":27,"depth":12,"text":28},{"id":30,"depth":12,"text":31},"How to make dynamic content changes visible to screen readers using aria-live regions, and how to wrap it into a reusable composable.","md",{},"\u002Fblog\u002Fuse-announcer-nuxt",{"title":34,"description":814},[820,821,822],"VUE","A11Y","ARIA","blog\u002Fuse-announcer-nuxt","ACCESSIBILITY","ws8av4j97wRPwOsbpcCqba7WCvV1RkzDK8C0DquaNtw",1780311892629]