Repository: vuejs/eslint-plugin-vue (js)

Issue:
Rule proposal: vue/prefer-use-template-ref
**Please describe what the rule should do:**
Vue 3.5 released a new preferred method of binding to template refs, `useTemplateRef`. This rule suggests using the new API over the old plain ref style. It has better dev tools support, supports dynamic keys and is more self-documenting.

**What category should the rule belong to?**
<!-- (place an "X" next to just one item) -->
[ ] Enforces code style (layout)
[ ] Warns about a potential error (problem)
[X] Suggests an alternate way of doing something (suggestion)
[ ] Other (please specify:)

**Provide 2-3 code examples that this rule should warn about:**

```vue
<template>
  <input ref="input">
</template>

<script setup>
<!-- BAD -->
const input = ref();

<!-- GOOD -->
import { useTemplateRef } from 'vue'

const input = useTemplateRef('input')
</script>
```

**Additional context**
https://vuejs.org/api/composition-api-helpers.html#usetemplateref Docs about the API
https://blog.vuejs.org/posts/vue-3-5#usetemplateref Reasoning for the the new recommendation.

Pull request description:
feat: add prefer-use-template-ref rule
resolves #2549 

Patch:
diff --git a/docs/rules/index.md b/docs/rules/index.md
index 1ba5978d..4085ce23 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -270,6 +270,7 @@ For example:
 | [vue/prefer-prop-type-boolean-first](./prefer-prop-type-boolean-first.md) | enforce `Boolean` comes first in component prop types | :bulb: | :warning: |
 | [vue/prefer-separate-static-class](./prefer-separate-static-class.md) | require static class names in template to be in a separate `class` attribute | :wrench: | :hammer: |
 | [vue/prefer-true-attribute-shorthand](./prefer-true-attribute-shorthand.md) | require shorthand form attribute when `v-bind` value is `true` | :bulb: | :hammer: |
+| [vue/prefer-use-template-ref](./prefer-use-template-ref.md) | require using `useTemplateRef` instead of `ref` for template refs |  | :hammer: |
 | [vue/require-default-export](./require-default-export.md) | require components to be the default export |  | :warning: |
 | [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported |  | :hammer: |
 | [vue/require-emit-validator](./require-emit-validator.md) | require type definitions in emits | :bulb: | :hammer: |
diff --git a/docs/rules/no-reserved-component-names.md b/docs/rules/no-reserved-component-names.md
index a5d85cde..31b74ab0 100644
--- a/docs/rules/no-reserved-component-names.md
+++ b/docs/rules/no-reserved-component-names.md
@@ -35,13 +35,15 @@ export default {
 {
   "vue/no-reserved-component-names": ["error", {
     "disallowVueBuiltInComponents": false,
-    "disallowVue3BuiltInComponents": false
+    "disallowVue3BuiltInComponents": false,
+    "htmlElementCaseSensitive": false,
   }]
 }
 ```
 
 - `disallowVueBuiltInComponents` (`boolean`) ... If `true`, disallow Vue.js 2.x built-in component names. Default is `false`.
 - `disallowVue3BuiltInComponents` (`boolean`) ... If `true`, disallow Vue.js 3.x built-in component names. Default is `false`.
+- `htmlElementCaseSensitive` (`boolean`) ... If `true`, component names must exactly match the case of an HTML element to be considered conflicting. Default is `false` (i.e. case-insensitve comparison).
 
 ### `"disallowVueBuiltInComponents": true`
 
@@ -73,6 +75,34 @@ export default {
 
 </eslint-code-block>
 
+### `"htmlElementCaseSensitive": true`
+
+<eslint-code-block :rules="{'vue/no-reserved-component-names': ['error', {htmlElementCaseSensitive: true}]}">
+
+```vue
+<script>
+/* ✓ GOOD */
+export default {
+  name: 'Button'
+}
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/no-reserved-component-names': ['error', {htmlElementCaseSensitive: true}]}">
+
+```vue
+<script>
+/* ✗ BAD */
+export default {
+  name: 'button'
+}
+</script>
+```
+
+</eslint-code-block>
+
 ## :couple: Related Rules
 
 - [vue/multi-word-component-names](./multi-word-component-names.md)
diff --git a/docs/rules/prefer-use-template-ref.md b/docs/rules/prefer-use-template-ref.md
new file mode 100644
index 00000000..6d7fde89
--- /dev/null
+++ b/docs/rules/prefer-use-template-ref.md
@@ -0,0 +1,71 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/prefer-use-template-ref
+description: require using `useTemplateRef` instead of `ref` for template refs
+---
+
+# vue/prefer-use-template-ref
+
+> require using `useTemplateRef` instead of `ref` for template refs
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> _**This rule has not been released yet.**_ </badge>
+
+## :book: Rule Details
+
+Vue 3.5 introduced a new way of obtaining template refs via
+the [`useTemplateRef()`](https://vuejs.org/guide/essentials/template-refs.html#accessing-the-refs) API.
+
+This rule enforces using the new `useTemplateRef` function instead of `ref` for template refs.
+
+<eslint-code-block :rules="{'vue/prefer-use-template-ref': ['error']}">
+
+```vue
+<template>
+  <button ref="submitRef">Submit</button>
+  <button ref="closeRef">Close</button>
+</template>
+
+<script setup>
+  import { ref, useTemplateRef } from 'vue';
+
+  /* ✓ GOOD */
+  const submitRef = useTemplateRef('submitRef');
+  const submitButton = useTemplateRef('submitRef');
+  const closeButton = useTemplateRef('closeRef');
+
+  /* ✗ BAD */
+  const closeRef = ref();
+</script>
+```
+
+</eslint-code-block>
+
+This rule skips `ref` template function refs as these should be used to allow custom implementation of storing `ref`. If you prefer
+`useTemplateRef`, you have to change the value of the template `ref` to a string.
+
+<eslint-code-block :rules="{'vue/prefer-use-template-ref': ['error']}">
+
+```vue
+<template>
+  <button :ref="ref => button = ref">Content</button>
+</template>
+
+<script setup>
+  import { ref } from 'vue';
+  
+  /* ✓ GOOD */
+  const button = ref();
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/prefer-use-template-ref.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/prefer-use-template-ref.js)
diff --git a/lib/index.js b/lib/index.js
index bb4abf40..b09e247d 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -208,6 +208,7 @@ const plugin = {
     'prefer-separate-static-class': require('./rules/prefer-separate-static-class'),
     'prefer-template': require('./rules/prefer-template'),
     'prefer-true-attribute-shorthand': require('./rules/prefer-true-attribute-shorthand'),
+    'prefer-use-template-ref': require('./rules/prefer-use-template-ref'),
     'prop-name-casing': require('./rules/prop-name-casing'),
     'quote-props': require('./rules/quote-props'),
     'require-component-is': require('./rules/require-component-is'),
diff --git a/lib/rules/define-macros-order.js b/lib/rules/define-macros-order.js
index 55e6140c..0bdd5f21 100644
--- a/lib/rules/define-macros-order.js
+++ b/lib/rules/define-macros-order.js
@@ -53,6 +53,7 @@ function isUseStrictStatement(node) {
 function getTargetStatementPosition(scriptSetup, program) {
   const skipStatements = new Set([
     'ImportDeclaration',
+    'TSModuleDeclaration',
     'TSInterfaceDeclaration',
     'TSTypeAliasDeclaration',
     'DebuggerStatement',
diff --git a/lib/rules/no-reserved-component-names.js b/lib/rules/no-reserved-component-names.js
index 90e53604..56621b42 100644
--- a/lib/rules/no-reserved-component-names.js
+++ b/lib/rules/no-reserved-component-names.js
@@ -34,19 +34,6 @@ function isLowercase(word) {
   return /^[a-z]*$/.test(word)
 }
 
-const RESERVED_NAMES_IN_HTML = new Set([
-  ...htmlElements,
-  ...htmlElements.map(casing.capitalize)
-])
-const RESERVED_NAMES_IN_OTHERS = new Set([
-  ...deprecatedHtmlElements,
-  ...deprecatedHtmlElements.map(casing.capitalize),
-  ...kebabCaseElements,
-  ...kebabCaseElements.map(casing.pascalCase),
-  ...svgElements,
-  ...svgElements.filter(isLowercase).map(casing.capitalize)
-])
-
 /**
  * @param {Expression | SpreadElement} node
  * @returns {node is (Literal | TemplateLiteral)}
@@ -61,14 +48,14 @@ function canVerify(node) {
 }
 
 /**
- * @param {string} name
- * @returns {string}
+ * @template T
+ * @param {Set<T>} set
+ * @param {Iterable<T>} iterable
  */
-function getMessageId(name) {
-  if (RESERVED_NAMES_IN_HTML.has(name)) return 'reservedInHtml'
-  if (RESERVED_NAMES_IN_VUE.has(name)) return 'reservedInVue'
-  if (RESERVED_NAMES_IN_VUE3.has(name)) return 'reservedInVue3'
-  return 'reserved'
+function addAll(set, iterable) {
+  for (const element of iterable) {
+    set.add(element)
+  }
 }
 
 module.exports = {
@@ -90,6 +77,9 @@ module.exports = {
           },
           disallowVue3BuiltInComponents: {
             type: 'boolean'
+          },
+          htmlElementCaseSensitive: {
+            type: 'boolean'
           }
         },
         additionalProperties: false
@@ -109,6 +99,23 @@ module.exports = {
       options.disallowVueBuiltInComponents === true
     const disallowVue3BuiltInComponents =
       options.disallowVue3BuiltInComponents === true
+    const htmlElementCaseSensitive = options.htmlElementCaseSensitive === true
+
+    const RESERVED_NAMES_IN_HTML = new Set(htmlElements)
+    const RESERVED_NAMES_IN_OTHERS = new Set([
+      ...deprecatedHtmlElements,
+      ...kebabCaseElements,
+      ...svgElements
+    ])
+
+    if (!htmlElementCaseSensitive) {
+      addAll(RESERVED_NAMES_IN_HTML, htmlElements.map(casing.capitalize))
+      addAll(RESERVED_NAMES_IN_OTHERS, [
+        ...deprecatedHtmlElements.map(casing.capitalize),
+        ...kebabCaseElements.map(casing.pascalCase),
+        ...svgElements.filter(isLowercase).map(casing.capitalize)
+      ])
+    }
 
     const reservedNames = new Set([
       ...RESERVED_NAMES_IN_HTML,
@@ -117,6 +124,17 @@ module.exports = {
       ...RESERVED_NAMES_IN_OTHERS
     ])
 
+    /**
+     * @param {string} name
+     * @returns {string}
+     */
+    function getMessageId(name) {
+      if (RESERVED_NAMES_IN_HTML.has(name)) return 'reservedInHtml'
+      if (RESERVED_NAMES_IN_VUE.has(name)) return 'reservedInVue'
+      if (RESERVED_NAMES_IN_VUE3.has(name)) return 'reservedInVue3'
+      return 'reserved'
+    }
+
     /**
      * @param {Literal | TemplateLiteral} node
      */
diff --git a/lib/rules/no-unused-emit-declarations.js b/lib/rules/no-unused-emit-declarations.js
index 14643893..f13784d8 100644
--- a/lib/rules/no-unused-emit-declarations.js
+++ b/lib/rules/no-unused-emit-declarations.js
@@ -56,18 +56,6 @@ function hasReferenceId(value, setupContext) {
   )
 }
 
-/**
- * Check if the given name matches emitReferenceIds variable name
- * @param {string} name
- * @param {Set<Identifier>} emitReferenceIds
- * @returns {boolean}
- */
-function isEmitVariableName(name, emitReferenceIds) {
-  if (emitReferenceIds.size === 0) return false
-  const emitVariable = emitReferenceIds.values().next().value.name
-  return emitVariable === name
-}
-
 module.exports = {
   meta: {
     type: 'suggestion',
@@ -91,6 +79,7 @@ module.exports = {
     /** @type {Map<ObjectExpression | Program, SetupContext>} */
     const setupContexts = new Map()
     const programNode = context.getSourceCode().ast
+    let emitParamName = ''
 
     /**
      * @param {CallExpression} node
@@ -204,14 +193,6 @@ module.exports = {
 
           const { contextReferenceIds, emitReferenceIds } = setupContext
 
-          // verify defineEmits variable in template
-          if (
-            callee.type === 'Identifier' &&
-            isEmitVariableName(callee.name, emitReferenceIds)
-          ) {
-            addEmitCall(node)
-          }
-
           // verify setup(props,{emit}) {emit()}
           addEmitCallByReference(callee, emitReferenceIds, node)
           if (emit && emit.name === 'emit') {
@@ -229,8 +210,11 @@ module.exports = {
           }
         }
 
-        // verify $emit() in template
-        if (callee.type === 'Identifier' && callee.name === '$emit') {
+        // verify $emit() and defineEmits variable in template
+        if (
+          callee.type === 'Identifier' &&
+          (callee.name === '$emit' || callee.name === emitParamName)
+        ) {
           addEmitCall(node)
         }
       }
@@ -316,10 +300,15 @@ module.exports = {
           }
 
           const emitParam = node.parent.id
-          const variable =
-            emitParam.type === 'Identifier'
-              ? findVariable(utils.getScope(context, emitParam), emitParam)
-              : null
+          if (emitParam.type !== 'Identifier') {
+            return
+          }
+
+          emitParamName = emitParam.name
+          const variable = findVariable(
+            utils.getScope(context, emitParam),
+            emitParam
+          )
           if (!variable) {
             return
           }
diff --git a/lib/rules/prefer-use-template-ref.js b/lib/rules/prefer-use-template-ref.js
new file mode 100644
index 00000000..8dcdccb3
--- /dev/null
+++ b/lib/rules/prefer-use-template-ref.js
@@ -0,0 +1,89 @@
+/**
+ * @author Thomasan1999
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../utils')
+
+/** @param expression {Expression | null} */
+function expressionIsRef(expression) {
+  // @ts-ignore
+  return expression?.callee?.name === 'ref'
+}
+
+/** @type {import("eslint").Rule.RuleModule} */
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description:
+        'require using `useTemplateRef` instead of `ref` for template refs',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/prefer-use-template-ref.html'
+    },
+    schema: [],
+    messages: {
+      preferUseTemplateRef: "Replace 'ref' with 'useTemplateRef'."
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    /** @type Set<string> */
+    const templateRefs = new Set()
+
+    /**
+     * @typedef ScriptRef
+     * @type {{node: Expression, ref: string}}
+     */
+
+    /**
+     * @type ScriptRef[] */
+    const scriptRefs = []
+
+    return utils.compositingVisitors(
+      utils.defineTemplateBodyVisitor(
+        context,
+        {
+          'VAttribute[directive=false]'(node) {
+            if (node.key.name === 'ref' && node.value?.value) {
+              templateRefs.add(node.value.value)
+            }
+          }
+        },
+        {
+          VariableDeclarator(declarator) {
+            if (!expressionIsRef(declarator.init)) {
+              return
+            }
+
+            scriptRefs.push({
+              // @ts-ignore
+              node: declarator.init,
+              // @ts-ignore
+              ref: declarator.id.name
+            })
+          }
+        }
+      ),
+      {
+        'Program:exit'() {
+          for (const templateRef of templateRefs) {
+            const scriptRef = scriptRefs.find(
+              (scriptRef) => scriptRef.ref === templateRef
+            )
+
+            if (!scriptRef) {
+              continue
+            }
+
+            context.report({
+              node: scriptRef.node,
+              messageId: 'preferUseTemplateRef'
+            })
+          }
+        }
+      }
+    )
+  }
+}
diff --git a/lib/rules/require-explicit-emits.js b/lib/rules/require-explicit-emits.js
index ba5c406b..e912549e 100644
--- a/lib/rules/require-explicit-emits.js
+++ b/lib/rules/require-explicit-emits.js
@@ -71,22 +71,6 @@ function getNameParamNode(node) {
   return null
 }
 
-/**
- * Check if the given name matches defineEmitsNode variable name
- * @param {string} name
- * @param {CallExpression | undefined} defineEmitsNode
- * @returns {boolean}
- */
-function isEmitVariableName(name, defineEmitsNode) {
-  const node = defineEmitsNode?.parent
-
-  if (node?.type === 'VariableDeclarator' && node.id.type === 'Identifier') {
-    return name === node.id.name
-  }
-
-  return false
-}
-
 module.exports = {
   meta: {
     type: 'suggestion',
@@ -128,6 +112,7 @@ module.exports = {
     const vueEmitsDeclarations = new Map()
     /** @type {Map<ObjectExpression | Program, ComponentProp[]>} */
     const vuePropsDeclarations = new Map()
+    let emitParamName = ''
 
     /**
      * @typedef {object} VueTemplateDefineData
@@ -271,11 +256,7 @@ module.exports = {
           // e.g. $emit() / emit() in template
           if (
             callee.type === 'Identifier' &&
-            (callee.name === '$emit' ||
-              isEmitVariableName(
-                callee.name,
-                vueTemplateDefineData.defineEmits
-              ))
+            (callee.name === '$emit' || callee.name === emitParamName)
           ) {
             verifyEmit(
               vueTemplateDefineData.emits,
@@ -308,10 +289,15 @@ module.exports = {
             }
 
             const emitParam = node.parent.id
-            const variable =
-              emitParam.type === 'Identifier'
-                ? findVariable(utils.getScope(context, emitParam), emitParam)
-                : null
+            if (emitParam.type !== 'Identifier') {
+              return
+            }
+
+            emitParamName = emitParam.name
+            const variable = findVariable(
+              utils.getScope(context, emitParam),
+              emitParam
+            )
             if (!variable) {
               return
             }
diff --git a/lib/rules/require-explicit-slots.js b/lib/rules/require-explicit-slots.js
index 0ae77b59..f87503bb 100644
--- a/lib/rules/require-explicit-slots.js
+++ b/lib/rules/require-explicit-slots.js
@@ -35,6 +35,21 @@ function getSlotsName(node) {
   return null
 }
 
+/**
+ * @param {VElement} node
+ * @return {VAttribute | VDirective | undefined}
+ */
+function getSlotNameNode(node) {
+  return node.startTag.attributes.find(
+    (node) =>
+      (!node.directive && node.key.name === 'name') ||
+      (node.directive &&
+        node.key.name.name === 'bind' &&
+        node.key.argument?.type === 'VIdentifier' &&
+        node.key.argument?.name === 'name')
+  )
+}
+
 module.exports = {
   meta: {
     type: 'problem',
@@ -68,6 +83,19 @@ module.exports = {
     }
     const slotsDefined = new Set()
 
+    /**
+     * @param {VElement} node
+     * @param {string | undefined} slotName
+     */
+    function reportMissingSlot(node, slotName) {
+      if (!slotsDefined.has(slotName)) {
+        context.report({
+          node,
+          messageId: 'requireExplicitSlots'
+        })
+      }
+    }
+
     return utils.compositingVisitors(
       utils.defineScriptSetupVisitor(context, {
         onDefineSlotsEnter(node) {
@@ -137,20 +165,27 @@ module.exports = {
         }
       }),
       utils.defineTemplateBodyVisitor(context, {
+        /** @param {VElement} node */
         "VElement[name='slot']"(node) {
-          let slotName = 'default'
-
-          const slotNameAttr = utils.getAttribute(node, 'name')
+          const nameNode = getSlotNameNode(node)
 
-          if (slotNameAttr?.value) {
-            slotName = slotNameAttr.value.value
+          // if no slot name is declared, default to 'default'
+          if (!nameNode) {
+            reportMissingSlot(node, 'default')
+            return
           }
 
-          if (!slotsDefined.has(slotName)) {
-            context.report({
-              node,
-              messageId: 'requireExplicitSlots'
-            })
+          if (nameNode.directive) {
+            const expression = nameNode.value?.expression
+            // ignore attribute binding except string literal
+            if (!expression || !utils.isStringLiteral(expression)) {
+              return
+            }
+
+            const name = utils.getStringLiteralValue(expression) || undefined
+            reportMissingSlot(node, name)
+          } else {
+            reportMissingSlot(node, nameNode.value?.value)
           }
         }
       })


Test patch:
diff --git a/tests/lib/rules/define-macros-order.js b/tests/lib/rules/define-macros-order.js
index 72b35986..3def7cf1 100644
--- a/tests/lib/rules/define-macros-order.js
+++ b/tests/lib/rules/define-macros-order.js
@@ -126,6 +126,8 @@ tester.run('define-macros-order', rule, {
       code: `
         <script setup lang="ts">
           import { bar } from 'foo'
+          declare global {}
+          declare namespace Namespace {}
           export interface Props {
             msg?: string
             labels?: string[]
diff --git a/tests/lib/rules/no-reserved-component-names.js b/tests/lib/rules/no-reserved-component-names.js
index 41cddcfd..231e1f98 100644
--- a/tests/lib/rules/no-reserved-component-names.js
+++ b/tests/lib/rules/no-reserved-component-names.js
@@ -410,6 +410,16 @@ const invalidElements = [
   'xmp',
   'Xmp'
 ]
+const invalidLowerCaseElements = []
+const invalidUpperCaseElements = []
+
+for (const element of invalidElements) {
+  if (element[0] === element[0].toLowerCase()) {
+    invalidLowerCaseElements.push(element)
+  } else {
+    invalidUpperCaseElements.push(element)
+  }
+}
 
 const vue2BuiltInComponents = [
   'component',
@@ -559,6 +569,16 @@ ruleTester.run('no-reserved-component-names', rule, {
       languageOptions,
       options: [{ disallowVueBuiltInComponents: true }]
     })),
+    ...invalidUpperCaseElements.map((name) => ({
+      filename: `${name}.vue`,
+      code: `
+          export default {
+            name: '${name}'
+          }
+        `,
+      languageOptions,
+      options: [{ htmlElementCaseSensitive: true }]
+    })),
     {
       filename: 'test.vue',
       code: `<script setup> defineOptions({}) </script>`,
@@ -701,6 +721,24 @@ ruleTester.run('no-reserved-component-names', rule, {
         }
       ]
     })),
+    ...invalidLowerCaseElements.map((name) => ({
+      filename: `${name}.vue`,
+      code: `<script setup> defineOptions({name: '${name}'}) </script>`,
+      languageOptions: {
+        parser: require('vue-eslint-parser'),
+        ...languageOptions
+      },
+      options: [{ htmlElementCaseSensitive: true }],
+      errors: [
+        {
+          messageId: RESERVED_NAMES_IN_HTML.has(name)
+            ? 'reservedInHtml'
+            : 'reserved',
+          data: { name },
+          line: 1
+        }
+      ]
+    })),
     ...vue2BuiltInComponents.map((name) => ({
       filename: `${name}.vue`,
       code: `
diff --git a/tests/lib/rules/prefer-use-template-ref.js b/tests/lib/rules/prefer-use-template-ref.js
new file mode 100644
index 00000000..49a2f075
--- /dev/null
+++ b/tests/lib/rules/prefer-use-template-ref.js
@@ -0,0 +1,323 @@
+/**
+ * @author Thomasan1999
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('../../eslint-compat').RuleTester
+const rule = require('../../../lib/rules/prefer-use-template-ref')
+
+const tester = new RuleTester({
+  languageOptions: {
+    parser: require('vue-eslint-parser'),
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('prefer-use-template-ref', rule, {
+  valid: [
+    {
+      filename: 'single-use-template-ref.vue',
+      code: `
+      <template>
+          <div ref="root" />
+      </template>
+      <script setup>
+        import { useTemplateRef } from 'vue';
+        const root = useTemplateRef('root');
+      </script>
+      `
+    },
+    {
+      filename: 'multiple-use-template-refs.vue',
+      code: `
+      <template>
+          <button ref="button">Content</button>
+          <a href="" ref="link">Link</a>
+      </template>
+      <script setup>
+        import { useTemplateRef } from 'vue';
+        const buttonRef = useTemplateRef('button');
+        const link = useTemplateRef('link');
+      </script>
+      `
+    },
+    {
+      filename: 'use-template-ref-in-block.vue',
+      code: `
+      <template>
+          <div>
+            <ul>
+              <li ref="firstListItem">Morning</li>
+              <li>Afternoon</li>
+              <li>Evening</li>
+            </ul>
+          </div>
+      </template>
+      <script setup>
+        import { useTemplateRef } from 'vue';
+        function getFirstListItemElement() {
+          const firstListItem = useTemplateRef('firstListItem')
+          console.log(firstListItem)
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'non-template-ref.vue',
+      code: `
+      <template>
+          <div>
+            <ul>
+              <li v-for="food in foods" :key="food">{{food}}</li>
+            </ul>
+          </div>
+      </template>
+      <script setup>
+        import { ref } from 'vue';
+        const foods = ref(['Spaghetti', 'Pizza', 'Cake']);
+      </script>
+      `
+    },
+    {
+      filename: 'counter.js',
+      code: `
+      import { ref } from 'vue';
+      const counter = ref(0);
+      const names = ref(new Set());
+      function incrementCounter() {
+        counter.value++;
+        return counter.value;
+      }
+      function storeName(name) {
+        names.value.add(name)
+      }
+      `
+    },
+    {
+      filename: 'setup-function.vue',
+      code: `
+      <template>
+        <p>Button clicked {{counter}} times.</p>
+        <button ref="button" @click="counter++">Click</button>
+      </template>
+      <script>
+        import { ref, useTemplateRef } from 'vue';
+        export default {
+          name: 'Counter',
+          setup() {
+            const counter = ref(0);
+            const button = useTemplateRef('button');
+          }
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'options-api-no-refs.vue',
+      code: `
+      <template>
+        <label ref="label">
+          Name:
+          <input v-model="name" />
+        </label>
+        <p ref="textRef">{{niceName}}</p>
+        <button
+      </template>
+      <script>
+        import { ref } from 'vue';
+        export default {
+          name: 'NameRow',
+          methods: {
+            someFunction() {
+              return {
+                label: ref(5),
+              }
+            }
+          }
+          data() {
+            return {
+              label: 'label',
+              name: ''
+            }
+          },
+          computed: {
+            niceName() {
+              return 'Nice ' + this.name;
+            }
+          }
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'options-api-mixed.vue',
+      code: `
+      <template>
+        <label ref="labelElem">
+          Name:
+          <input v-model="name" />
+        </label>
+        {{ loremIpsum }}
+      </template>
+      <script>
+        import { ref } from 'vue';
+        export default {
+          name: 'NameRow',
+          props: {
+            defaultLabel: {
+              type: String,
+            },
+          },
+          data() {
+            return {
+              label: ref(this.defaultLabel),
+              labelElem: ref(),
+              name: ''
+            }
+          },
+          computed: {
+            loremIpsum() {
+              return this.name + ' lorem ipsum'
+            }
+          }
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'template-ref-function.vue',
+      code: `
+      <template>
+        <button :ref="ref => button = ref">Content</button>
+      </template>
+      <script setup>
+        import { ref } from 'vue';
+        const button = ref();
+        </script>
+      `
+    }
+  ],
+  invalid: [
+    {
+      filename: 'single-ref.vue',
+      code: `
+      <template>
+          <div ref="root"/>
+      </template>
+      <script setup>
+        import { ref } from 'vue';
+        const root = ref();
+      </script>
+      `,
+      errors: [
+        {
+          messageId: 'preferUseTemplateRef',
+          line: 7,
+          column: 22
+        }
+      ]
+    },
+    {
+      filename: 'one-ref-unused-in-script.vue',
+      code: `
+      <template>
+          <button ref="button">Content</button>
+          <a href="" ref="link">Link</a>
+      </template>
+      <script setup>
+        import { ref } from 'vue';
+        const buttonRef = ref();
+        const link = ref();
+      </script>
+      `,
+      errors: [
+        {
+          messageId: 'preferUseTemplateRef',
+          line: 9,
+          column: 22
+        }
+      ]
+    },
+    {
+      filename: 'multiple-refs.vue',
+      code: `
+      <template>
+          <h1 ref="heading">Heading</h1>
+          <a href="" ref="link">Link</a>
+      </template>
+      <script setup>
+        import { ref } from 'vue';
+        const heading = ref();
+        const link = ref();
+      </script>
+      `,
+      errors: [
+        {
+          messageId: 'preferUseTemplateRef',
+          line: 8,
+          column: 25
+        },
+        {
+          messageId: 'preferUseTemplateRef',
+          line: 9,
+          column: 22
+        }
+      ]
+    },
+    {
+      filename: 'ref-in-block.vue',
+      code: `
+      <template>
+          <div>
+            <ul>
+              <li ref="firstListItem">Morning</li>
+              <li>Afternoon</li>
+              <li>Evening</li>
+            </ul>
+          </div>
+      </template>
+      <script setup>
+        import { ref } from 'vue';
+        function getFirstListItemElement() {
+          const firstListItem = ref();
+        }
+      </script>
+      `,
+      errors: [
+        {
+          messageId: 'preferUseTemplateRef',
+          line: 14,
+          column: 33
+        }
+      ]
+    },
+    {
+      filename: 'setup-function-only-refs.vue',
+      code: `
+      <template>
+        <p>Button clicked {{counter}} times.</p>
+        <button ref="button">Click</button>
+      </template>
+      <script>
+        import { ref } from 'vue';
+        export default {
+          name: 'Counter',
+          setup() {
+            const counter = ref(0);
+            const button = ref();
+          }
+        }
+      </script>
+      `,
+      errors: [
+        {
+          messageId: 'preferUseTemplateRef',
+          line: 12,
+          column: 28
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/require-explicit-slots.js b/tests/lib/rules/require-explicit-slots.js
index ebbc2881..92d1a133 100644
--- a/tests/lib/rules/require-explicit-slots.js
+++ b/tests/lib/rules/require-explicit-slots.js
@@ -160,6 +160,37 @@ tester.run('require-explicit-slots', rule, {
           parser: null
         }
       }
+    },
+    // attribute binding
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot :name="'foo'"></slot>
+          <slot :name="\`bar\`"></slot>
+        </div>
+      </template>
+      <script setup lang="ts">
+      defineSlots<{
+        foo(props: { msg: string }): any
+        bar(props: { msg: string }): any
+      }>()
+      </script>`
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot :name="bar"></slot>
+        </div>
+      </template>
+      <script setup lang="ts">
+      defineSlots<{
+        default(props: { msg: string }): any
+      }>()
+      </script>`
     }
   ],
   invalid: [
@@ -291,6 +322,26 @@ tester.run('require-explicit-slots', rule, {
         }
       ]
     },
+    {
+      // ignore attribute binding except string literal
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div>
+          <slot :name="'foo'"></slot>
+        </div>
+      </template>
+      <script setup lang="ts">
+      defineSlots<{
+        default(props: { msg: string }): any
+      }>()
+      </script>`,
+      errors: [
+        {
+          message: 'Slots must be explicitly defined.'
+        }
+      ]
+    },
     {
       filename: 'test.vue',
       code: `