diff --git a/.changeset/fix-use-alt-text.md b/.changeset/fix-use-alt-text.md
new file mode 100644
index 000000000000..23bca5c474ad
--- /dev/null
+++ b/.changeset/fix-use-alt-text.md
@@ -0,0 +1,5 @@
+---
+"@biomejs/biome": patch
+---
+
+Fixed [#9349](https://github.com/biomejs/biome/issues/9349): Biome now correctly handles Vue dynamic `:alt` and `v-bind:alt` bindings in `useAltText`, preventing false positives in `.vue` files.
diff --git a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs
index 49e6f254c97f..b514acb86e1b 100644
--- a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs
+++ b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs
@@ -200,8 +200,5 @@ fn has_type_image_attribute(element: &AnyHtmlElement) -> bool {
/// Check if the element has a valid alt attribute
fn has_valid_alt_text(element: &AnyHtmlElement) -> bool {
- // The alt attribute exists - even an empty alt="" is valid for decorative images
- // If there's no initializer, it's treated as an empty string (valid)
- // If there's an initializer with a value, any value is valid
- element.find_attribute_by_name("alt").is_some()
+ element.find_attribute_or_vue_binding("alt").is_some()
}
diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue b/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue
new file mode 100644
index 000000000000..7c1cc69992a2
--- /dev/null
+++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue.snap b/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue.snap
new file mode 100644
index 000000000000..0b495869cea6
--- /dev/null
+++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue.snap
@@ -0,0 +1,18 @@
+---
+source: crates/biome_html_analyze/tests/spec_tests.rs
+expression: valid.vue
+---
+# Input
+```html
+
+
+
+
+
+
+
+
+
+
+
+```
diff --git a/crates/biome_html_syntax/src/element_ext.rs b/crates/biome_html_syntax/src/element_ext.rs
index d8465c65fc0c..348dae1eed05 100644
--- a/crates/biome_html_syntax/src/element_ext.rs
+++ b/crates/biome_html_syntax/src/element_ext.rs
@@ -1,9 +1,10 @@
use crate::{
- AnyHtmlContent, AnyHtmlElement, AnyHtmlTagName, AnyHtmlTextExpression, AnySvelteBlock,
- AstroEmbeddedContent, HtmlAttribute, HtmlAttributeList, HtmlElement, HtmlEmbeddedContent,
- HtmlOpeningElement, HtmlSelfClosingElement, HtmlSyntaxToken, HtmlTagName, ScriptType,
- inner_string_text,
+ AnyHtmlAttribute, AnyHtmlContent, AnyHtmlElement, AnyHtmlTagName, AnyHtmlTextExpression,
+ AnySvelteBlock, AnyVueDirective, AstroEmbeddedContent, HtmlAttribute, HtmlAttributeList,
+ HtmlElement, HtmlEmbeddedContent, HtmlOpeningElement, HtmlSelfClosingElement, HtmlSyntaxToken,
+ HtmlTagName, ScriptType, inner_string_text,
};
+
use biome_rowan::{AstNodeList, SyntaxResult, TokenText, declare_node_union};
/// https://html.spec.whatwg.org/#void-elements
@@ -123,6 +124,58 @@ impl AnyHtmlElement {
_ => None,
}
}
+
+ /// Check if the element has a given HTML attribute or a Vue v-bind binding
+ /// targeting the same attribute name.
+ ///
+ /// Handles:
+ /// - `name="..."` — standard HTML attribute
+ /// - `:name="..."` — Vue v-bind shorthand (`VueVBindShorthandDirective`)
+ /// - `v-bind:name="..."` — explicit Vue v-bind (`VueDirective`)
+ pub fn find_attribute_or_vue_binding(&self, name_to_lookup: &str) -> Option {
+ let attrs = self.attributes()?;
+
+ attrs.iter().find_map(|attr| {
+ let matches = match &attr {
+ AnyHtmlAttribute::HtmlAttribute(a) => a
+ .name()
+ .ok()
+ .and_then(|n| n.value_token().ok())
+ .is_some_and(|t| t.text_trimmed().eq_ignore_ascii_case(name_to_lookup)),
+
+ AnyHtmlAttribute::AnyVueDirective(vue) => match vue {
+ // :name="..."
+ AnyVueDirective::VueVBindShorthandDirective(d) => d
+ .arg()
+ .ok()
+ .and_then(|arg| arg.arg().ok())
+ .and_then(|arg| arg.as_vue_static_argument().cloned())
+ .and_then(|s| s.name_token().ok())
+ .is_some_and(|t| t.text_trimmed().eq_ignore_ascii_case(name_to_lookup)),
+
+ // v-bind:name="..."
+ AnyVueDirective::VueDirective(d) => {
+ let is_bind = d
+ .name_token()
+ .is_ok_and(|t| t.text_trimmed().eq_ignore_ascii_case("v-bind"));
+ is_bind
+ && d.arg()
+ .and_then(|arg| arg.arg().ok())
+ .and_then(|arg| arg.as_vue_static_argument().cloned())
+ .and_then(|s| s.name_token().ok())
+ .is_some_and(|t| {
+ t.text_trimmed().eq_ignore_ascii_case(name_to_lookup)
+ })
+ }
+
+ _ => false,
+ },
+
+ _ => false,
+ };
+ if matches { Some(attr) } else { None }
+ })
+ }
}
impl HtmlSelfClosingElement {