diff --git a/packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineAttrs.spec.ts.snap b/packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineAttrs.spec.ts.snap
new file mode 100644
index 00000000000..80d066e8a67
--- /dev/null
+++ b/packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineAttrs.spec.ts.snap
@@ -0,0 +1,46 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`defineAttrs() > basic usage 1`] = `
+"import { useAttrs as _useAttrs, defineComponent as _defineComponent } from 'vue'
+
+export default /*#__PURE__*/_defineComponent({
+  setup(__props, { expose: __expose }) {
+  __expose();
+
+      const attrs = _useAttrs()
+      
+return { attrs }
+}
+
+})"
+`;
+
+exports[`defineAttrs() > w/o generic params 1`] = `
+"import { useAttrs as _useAttrs } from 'vue'
+
+export default {
+  setup(__props, { expose: __expose }) {
+  __expose();
+
+      const attrs = _useAttrs()
+      
+return { attrs }
+}
+
+}"
+`;
+
+exports[`defineAttrs() > w/o return value 1`] = `
+"import { defineComponent as _defineComponent } from 'vue'
+
+export default /*#__PURE__*/_defineComponent({
+  setup(__props, { expose: __expose }) {
+  __expose();
+
+      
+      
+return {  }
+}
+
+})"
+`;
diff --git a/packages/compiler-sfc/__tests__/compileScript/defineAttrs.spec.ts b/packages/compiler-sfc/__tests__/compileScript/defineAttrs.spec.ts
new file mode 100644
index 00000000000..e155e3c6bfd
--- /dev/null
+++ b/packages/compiler-sfc/__tests__/compileScript/defineAttrs.spec.ts
@@ -0,0 +1,40 @@
+import { compileSFCScript as compile, assertCode } from '../utils'
+
+describe('defineAttrs()', () => {
+  test('basic usage', () => {
+    const { content } = compile(`
+      <script setup lang="ts">
+      const attrs = defineAttrs<{
+        bar?: number
+      }>()
+      </script>
+    `)
+    assertCode(content)
+    expect(content).toMatch(`const attrs = _useAttrs()`)
+    expect(content).not.toMatch('defineAttrs')
+  })
+
+  test('w/o return value', () => {
+    const { content } = compile(`
+      <script setup lang="ts">
+      defineAttrs<{
+        bar?: number
+      }>()
+      </script>
+    `)
+    assertCode(content)
+    expect(content).not.toMatch('defineAttrs')
+    expect(content).not.toMatch(`_useAttrs`)
+  })
+
+  test('w/o generic params', () => {
+    const { content } = compile(`
+      <script setup>
+      const attrs = defineAttrs()
+      </script>
+    `)
+    assertCode(content)
+    expect(content).toMatch(`const attrs = _useAttrs()`)
+    expect(content).not.toMatch('defineAttrs')
+  })
+})
diff --git a/packages/compiler-sfc/src/compileScript.ts b/packages/compiler-sfc/src/compileScript.ts
index 2a33f69936d..f5c902f3df9 100644
--- a/packages/compiler-sfc/src/compileScript.ts
+++ b/packages/compiler-sfc/src/compileScript.ts
@@ -53,6 +53,7 @@ import {
 import { analyzeScriptBindings } from './script/analyzeScriptBindings'
 import { isImportUsed } from './script/importUsageCheck'
 import { processAwait } from './script/topLevelAwait'
+import { processDefineAttrs } from './script/defineAttrs'
 
 export interface SFCScriptCompileOptions {
   /**
@@ -512,7 +513,8 @@ export function compileScript(
         processDefineProps(ctx, expr) ||
         processDefineEmits(ctx, expr) ||
         processDefineOptions(ctx, expr) ||
-        processDefineSlots(ctx, expr)
+        processDefineSlots(ctx, expr) ||
+        processDefineAttrs(ctx, expr)
       ) {
         ctx.s.remove(node.start! + startOffset, node.end! + startOffset)
       } else if (processDefineExpose(ctx, expr)) {
@@ -550,7 +552,8 @@ export function compileScript(
             !isDefineProps && processDefineEmits(ctx, init, decl.id)
           !isDefineEmits &&
             (processDefineSlots(ctx, init, decl.id) ||
-              processDefineModel(ctx, init, decl.id))
+              processDefineModel(ctx, init, decl.id) ||
+              processDefineAttrs(ctx, init, decl.id))
 
           if (
             isDefineProps &&
diff --git a/packages/compiler-sfc/src/script/context.ts b/packages/compiler-sfc/src/script/context.ts
index 692eab3ab9e..0d1607ab46d 100644
--- a/packages/compiler-sfc/src/script/context.ts
+++ b/packages/compiler-sfc/src/script/context.ts
@@ -36,6 +36,7 @@ export class ScriptCompileContext {
   hasDefineOptionsCall = false
   hasDefineSlotsCall = false
   hasDefineModelCall = false
+  hasDefineAttrsCall = false
 
   // defineProps
   propsCall: CallExpression | undefined
diff --git a/packages/compiler-sfc/src/script/defineAttrs.ts b/packages/compiler-sfc/src/script/defineAttrs.ts
new file mode 100644
index 00000000000..d2eda8d9962
--- /dev/null
+++ b/packages/compiler-sfc/src/script/defineAttrs.ts
@@ -0,0 +1,33 @@
+import { LVal, Node } from '@babel/types'
+import { isCallOf } from './utils'
+import { ScriptCompileContext } from './context'
+
+export const DEFINE_ATTRS = 'defineAttrs'
+
+export function processDefineAttrs(
+  ctx: ScriptCompileContext,
+  node: Node,
+  declId?: LVal
+): boolean {
+  if (!isCallOf(node, DEFINE_ATTRS)) {
+    return false
+  }
+  if (ctx.hasDefineAttrsCall) {
+    ctx.error(`duplicate ${DEFINE_ATTRS}() call`, node)
+  }
+  ctx.hasDefineAttrsCall = true
+
+  if (node.arguments.length > 0) {
+    ctx.error(`${DEFINE_ATTRS}() cannot accept arguments`, node)
+  }
+
+  if (declId) {
+    ctx.s.overwrite(
+      ctx.startOffset! + node.start!,
+      ctx.startOffset! + node.end!,
+      `${ctx.helper('useAttrs')}()`
+    )
+  }
+
+  return true
+}
diff --git a/packages/dts-test/defineComponent.test-d.tsx b/packages/dts-test/defineComponent.test-d.tsx
index 7466249e10f..7a9e9737785 100644
--- a/packages/dts-test/defineComponent.test-d.tsx
+++ b/packages/dts-test/defineComponent.test-d.tsx
@@ -10,10 +10,13 @@ import {
   SetupContext,
   h,
   SlotsType,
+  AttrsType,
   Slots,
-  VNode
+  VNode,
+  ImgHTMLAttributes,
+  StyleValue
 } from 'vue'
-import { describe, expectType, IsUnion } from './utils'
+import { describe, expectType, IsUnion, test } from './utils'
 
 describe('with object props', () => {
   interface ExpectedProps {
@@ -1188,6 +1191,270 @@ describe('async setup', () => {
   vm.a = 2
 })
 
+describe('define attrs', () => {
+  test('define attrs w/ object props', () => {
+    const MyComp = defineComponent({
+      props: {
+        foo: String
+      },
+      attrs: Object as AttrsType<{
+        bar?: number
+      }>,
+      created() {
+        expectType<number | undefined>(this.$attrs.bar)
+      }
+    })
+    expectType<JSX.Element>(<MyComp foo="1" bar={1} />)
+  })
+
+  test('define attrs w/ array props', () => {
+    const MyComp = defineComponent({
+      props: ['foo'],
+      attrs: Object as AttrsType<{
+        bar?: number
+      }>,
+      created() {
+        expectType<number | undefined>(this.$attrs.bar)
+      }
+    })
+    expectType<JSX.Element>(<MyComp foo="1" bar={1} />)
+  })
+
+  test('define attrs w/ no props', () => {
+    const MyComp = defineComponent({
+      attrs: Object as AttrsType<{
+        bar?: number
+      }>,
+      created() {
+        expectType<number | undefined>(this.$attrs.bar)
+      }
+    })
+    expectType<JSX.Element>(<MyComp bar={1} />)
+  })
+
+  test('define attrs w/ composition api', () => {
+    const MyComp = defineComponent({
+      props: {
+        foo: {
+          type: String,
+          required: true
+        }
+      },
+      attrs: Object as AttrsType<{
+        bar?: number
+      }>,
+      setup(props, { attrs }) {
+        expectType<string>(props.foo)
+        expectType<number | undefined>(attrs.bar)
+      }
+    })
+    expectType<JSX.Element>(<MyComp foo="1" bar={1} />)
+  })
+
+  test('define attrs w/ functional component', () => {
+    const MyComp = defineComponent(
+      (props: { foo: string }, ctx) => {
+        expectType<number | undefined>(ctx.attrs.bar)
+        return () => (
+          // return a render function (both JSX and h() works)
+          <div>{props.foo}</div>
+        )
+      },
+      {
+        attrs: Object as AttrsType<{
+          bar?: number
+        }>
+      }
+    )
+    expectType<JSX.Element>(<MyComp foo={'1'} bar={1} />)
+  })
+
+  test('define attrs as low priority', () => {
+    const MyComp = defineComponent({
+      props: {
+        foo: String
+      },
+      attrs: Object as AttrsType<{
+        foo?: number
+      }>,
+      created() {
+        // @ts-expect-error
+        this.$attrs.foo
+
+        expectType<string | undefined>(this.foo)
+      }
+    })
+    expectType<JSX.Element>(<MyComp foo="1" />)
+  })
+
+  test('define required attrs', () => {
+    const MyComp = defineComponent({
+      attrs: Object as AttrsType<{
+        bar: number
+      }>,
+      created() {
+        expectType<number | undefined>(this.$attrs.bar)
+      }
+    })
+    expectType<JSX.Element>(<MyComp bar={1} />)
+    // @ts-expect-error
+    expectType<JSX.Element>(<MyComp />)
+  })
+
+  test('define no attrs w/ object props', () => {
+    const MyComp = defineComponent({
+      props: {
+        foo: String
+      },
+      created() {
+        expectType<unknown>(this.$attrs.bar)
+      }
+    })
+    // @ts-expect-error
+    expectType<JSX.Element>(<MyComp foo="1" bar={1} />)
+  })
+
+  test('define no attrs w/ functional component', () => {
+    const MyComp = defineComponent((props: { foo: string }, ctx) => {
+      expectType<unknown>(ctx.attrs.bar)
+      return () => (
+        // return a render function (both JSX and h() works)
+        <div>{props.foo}</div>
+      )
+    })
+    expectType<JSX.Element>(<MyComp foo={'1'} />)
+    // @ts-expect-error
+    expectType<JSX.Element>(<MyComp foo="1" bar={1} />)
+  })
+  test('wrap elements', () => {
+    const MyImg = defineComponent({
+      props: {
+        foo: String
+      },
+      attrs: Object as AttrsType<ImgHTMLAttributes>,
+      created() {
+        expectType<any>(this.$attrs.class)
+        expectType<StyleValue | undefined>(this.$attrs.style)
+      },
+      render() {
+        return <img {...this.$attrs} />
+      }
+    })
+    expectType<JSX.Element>(<MyImg class={'str'} style={'str'} src={'str'} />)
+  })
+
+  test('wrap components', () => {
+    const Child = defineComponent({
+      props: {
+        foo: String
+      },
+      emits: {
+        baz: (val: number) => true
+      },
+      render() {
+        return <div>{this.foo}</div>
+      }
+    })
+    const Comp = defineComponent({
+      props: {
+        bar: Number
+      },
+      attrs: Object as AttrsType<typeof Child>,
+      created() {
+        expectType<unknown>(this.$attrs.class)
+        expectType<unknown>(this.$attrs.style)
+      },
+      render() {
+        return <Child {...this.$attrs} />
+      }
+    })
+    expectType<JSX.Element>(
+      <Comp
+        class={'str'}
+        style={'str'}
+        bar={1}
+        foo={'str'}
+        onBaz={val => {
+          expectType<number>(val)
+        }}
+      />
+    )
+  })
+
+  test('wrap components w/ functional component', () => {
+    const Child = defineComponent((props: { foo: string }, ctx) => {
+      return () => <div>{props.foo}</div>
+    })
+    const Comp = defineComponent({
+      props: {
+        bar: Number
+      },
+      attrs: Object as AttrsType<typeof Child>,
+      created() {
+        expectType<unknown>(this.$attrs.class)
+        expectType<unknown>(this.$attrs.style)
+      },
+      render() {
+        return <Child {...this.$attrs} />
+      }
+    })
+    expectType<JSX.Element>(
+      <Comp class={'str'} style={'str'} bar={1} foo={'str'} />
+    )
+  })
+  test('ignore reserved props', () => {
+    defineComponent({
+      attrs: Object as AttrsType<{
+        bar?: number
+      }>,
+      created() {
+        // @ts-expect-error reserved props
+        this.$attrs.key
+        // @ts-expect-error reserved props
+        this.$attrs.ref
+      }
+    })
+    defineComponent(
+      (props: { foo: string }, ctx) => {
+        // @ts-expect-error reserved props
+        ctx.attrs.key
+        // @ts-expect-error reserved props
+        ctx.attrs.ref
+        return () => <div>{props.foo}</div>
+      },
+      {
+        attrs: Object as AttrsType<{
+          bar?: number
+        }>
+      }
+    )
+  })
+
+  test('always readonly', () => {
+    defineComponent({
+      attrs: Object as AttrsType<{
+        bar?: number
+      }>,
+      created() {
+        // @ts-expect-error readonly
+        this.$attrs.class = 'test'
+      }
+    })
+    defineComponent(
+      (props: { foo: string }, ctx) => {
+        // @ts-expect-error readonly
+        ctx.attrs.bar = 1
+        return () => <div>{props.foo}</div>
+      },
+      {
+        attrs: Object as AttrsType<{
+          bar?: number
+        }>
+      }
+    )
+  })
+})
+
 // #5948
 describe('DefineComponent should infer correct types when assigning to Component', () => {
   let component: Component
diff --git a/packages/dts-test/defineCustomElement.test-d.ts b/packages/dts-test/defineCustomElement.test-d.ts
index 4e7cf228372..5d56a65c0c8 100644
--- a/packages/dts-test/defineCustomElement.test-d.ts
+++ b/packages/dts-test/defineCustomElement.test-d.ts
@@ -1,5 +1,5 @@
-import { defineCustomElement } from 'vue'
-import { expectType, describe } from './utils'
+import { defineCustomElement, AttrsType } from 'vue'
+import { describe, expectType, test } from './utils'
 
 describe('inject', () => {
   // with object inject
@@ -62,3 +62,96 @@ describe('inject', () => {
     }
   })
 })
+
+describe('define attrs', () => {
+  test('define attrs w/ object props', () => {
+    defineCustomElement({
+      props: {
+        foo: String
+      },
+      attrs: Object as AttrsType<{
+        bar?: number
+      }>,
+      created() {
+        expectType<number | undefined>(this.$attrs.bar)
+      }
+    })
+  })
+
+  test('define attrs w/ array props', () => {
+    defineCustomElement({
+      props: ['foo'],
+      attrs: Object as AttrsType<{
+        bar?: number
+      }>,
+      created() {
+        expectType<number | undefined>(this.$attrs.bar)
+      }
+    })
+  })
+
+  test('define attrs w/ no props', () => {
+    defineCustomElement({
+      attrs: Object as AttrsType<{
+        bar?: number
+      }>,
+      created() {
+        expectType<number | undefined>(this.$attrs.bar)
+      }
+    })
+  })
+
+  test('define attrs as low priority', () => {
+    defineCustomElement({
+      props: {
+        foo: String
+      },
+      attrs: Object as AttrsType<{
+        foo: number
+      }>,
+      created() {
+        // @ts-expect-error
+        this.$attrs.foo
+        expectType<string | undefined>(this.foo)
+      }
+    })
+  })
+
+  test('define attrs w/ no attrs', () => {
+    defineCustomElement({
+      props: {
+        foo: String
+      },
+      created() {
+        expectType<unknown>(this.$attrs.bar)
+        expectType<unknown>(this.$attrs.baz)
+      }
+    })
+  })
+
+  test('default attrs like class, style', () => {
+    defineCustomElement({
+      props: {
+        foo: String
+      },
+      created() {
+        expectType<unknown>(this.$attrs.class)
+        expectType<unknown>(this.$attrs.style)
+      }
+    })
+  })
+
+  test('define required attrs', () => {
+    defineCustomElement({
+      props: {
+        foo: String
+      },
+      attrs: Object as AttrsType<{
+        bar: number
+      }>,
+      created() {
+        expectType<number>(this.$attrs.bar)
+      }
+    })
+  })
+})
diff --git a/packages/runtime-core/src/apiDefineComponent.ts b/packages/runtime-core/src/apiDefineComponent.ts
index 272bb548751..66f8de4b65b 100644
--- a/packages/runtime-core/src/apiDefineComponent.ts
+++ b/packages/runtime-core/src/apiDefineComponent.ts
@@ -8,12 +8,15 @@ import {
   RenderFunction,
   ComponentOptionsBase,
   ComponentInjectOptions,
-  ComponentOptions
+  ComponentOptions,
+  AttrsType,
+  UnwrapAttrsType
 } from './componentOptions'
 import {
   SetupContext,
   AllowedComponentProps,
-  ComponentCustomProps
+  ComponentCustomProps,
+  HasDefinedAttrs
 } from './component'
 import {
   ExtractPropTypes,
@@ -54,7 +57,8 @@ export type DefineComponent<
   PP = PublicProps,
   Props = ResolveProps<PropsOrPropOptions, E>,
   Defaults = ExtractDefaultPropTypes<PropsOrPropOptions>,
-  S extends SlotsType = {}
+  S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>
 > = ComponentPublicInstanceConstructor<
   CreateComponentPublicInstance<
     Props,
@@ -69,7 +73,8 @@ export type DefineComponent<
     Defaults,
     true,
     {},
-    S
+    S,
+    Attrs
   > &
     Props
 > &
@@ -86,7 +91,8 @@ export type DefineComponent<
     Defaults,
     {},
     string,
-    S
+    S,
+    Attrs
   > &
   PP
 
@@ -101,18 +107,23 @@ export function defineComponent<
   Props extends Record<string, any>,
   E extends EmitsOptions = {},
   EE extends string = string,
-  S extends SlotsType = {}
+  S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>,
+  PropsAttrs = HasDefinedAttrs<Attrs> extends true
+    ? UnwrapAttrsType<NonNullable<Attrs>>
+    : {}
 >(
   setup: (
     props: Props,
-    ctx: SetupContext<E, S>
+    ctx: SetupContext<E, S, Attrs>
   ) => RenderFunction | Promise<RenderFunction>,
   options?: Pick<ComponentOptions, 'name' | 'inheritAttrs'> & {
     props?: (keyof Props)[]
     emits?: E | EE[]
     slots?: S
+    attrs?: Attrs
   }
-): (props: Props & EmitsToProps<E>) => any
+): (props: Props & EmitsToProps<E> & PropsAttrs) => any
 export function defineComponent<
   Props extends Record<string, any>,
   E extends EmitsOptions = {},
@@ -144,6 +155,7 @@ export function defineComponent<
   E extends EmitsOptions = {},
   EE extends string = string,
   S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>,
   I extends ComponentInjectOptions = {},
   II extends string = string
 >(
@@ -159,7 +171,8 @@ export function defineComponent<
     EE,
     I,
     II,
-    S
+    S,
+    Attrs
   >
 ): DefineComponent<
   Props,
@@ -174,7 +187,8 @@ export function defineComponent<
   PublicProps,
   ResolveProps<Props, E>,
   ExtractDefaultPropTypes<Props>,
-  S
+  S,
+  Attrs
 >
 
 // overload 3: object format with array props declaration
@@ -191,6 +205,7 @@ export function defineComponent<
   E extends EmitsOptions = {},
   EE extends string = string,
   S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>,
   I extends ComponentInjectOptions = {},
   II extends string = string,
   Props = Readonly<{ [key in PropNames]?: any }>
@@ -207,7 +222,8 @@ export function defineComponent<
     EE,
     I,
     II,
-    S
+    S,
+    Attrs
   >
 ): DefineComponent<
   Props,
@@ -222,7 +238,8 @@ export function defineComponent<
   PublicProps,
   ResolveProps<Props, E>,
   ExtractDefaultPropTypes<Props>,
-  S
+  S,
+  Attrs
 >
 
 // overload 4: object format with object props declaration
@@ -241,7 +258,8 @@ export function defineComponent<
   EE extends string = string,
   S extends SlotsType = {},
   I extends ComponentInjectOptions = {},
-  II extends string = string
+  II extends string = string,
+  Attrs extends AttrsType = Record<string, unknown>
 >(
   options: ComponentOptionsWithObjectProps<
     PropsOptions,
@@ -255,7 +273,8 @@ export function defineComponent<
     EE,
     I,
     II,
-    S
+    S,
+    Attrs
   >
 ): DefineComponent<
   PropsOptions,
@@ -270,7 +289,8 @@ export function defineComponent<
   PublicProps,
   ResolveProps<PropsOptions, E>,
   ExtractDefaultPropTypes<PropsOptions>,
-  S
+  S,
+  Attrs
 >
 
 // implementation, close to no-op
diff --git a/packages/runtime-core/src/apiSetupHelpers.ts b/packages/runtime-core/src/apiSetupHelpers.ts
index 93200667081..b673d20e7f8 100644
--- a/packages/runtime-core/src/apiSetupHelpers.ts
+++ b/packages/runtime-core/src/apiSetupHelpers.ts
@@ -18,7 +18,8 @@ import {
   ComponentOptionsMixin,
   ComponentOptionsWithoutProps,
   ComputedOptions,
-  MethodOptions
+  MethodOptions,
+  StrictUnwrapAttrsType
 } from './componentOptions'
 import {
   ComponentPropsOptions,
@@ -215,6 +216,15 @@ export function defineSlots<
   return null as any
 }
 
+export function defineAttrs<
+  Attrs extends Record<string, any> = Record<string, any>
+>(): StrictUnwrapAttrsType<Attrs> {
+  if (__DEV__) {
+    warnRuntimeUsage(`defineAttrs`)
+  }
+  return null as any
+}
+
 /**
  * (**Experimental**) Vue `<script setup>` compiler macro for declaring a
  * two-way binding prop that can be consumed via `v-model` from the parent
diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts
index 57a53a39b76..491d3f9b0f8 100644
--- a/packages/runtime-core/src/component.ts
+++ b/packages/runtime-core/src/component.ts
@@ -40,10 +40,12 @@ import { AppContext, createAppContext, AppConfig } from './apiCreateApp'
 import { Directive, validateDirectiveName } from './directives'
 import {
   applyOptions,
+  AttrsType,
   ComponentOptions,
   ComputedOptions,
   MethodOptions,
-  resolveMergedOptions
+  resolveMergedOptions,
+  UnwrapAttrsType
 } from './componentOptions'
 import {
   EmitsOptions,
@@ -64,7 +66,8 @@ import {
   ShapeFlags,
   extend,
   getGlobalThis,
-  IfAny
+  IfAny,
+  Equal
 } from '@vue/shared'
 import { SuspenseBoundary } from './components/Suspense'
 import { CompilerOptions } from '@vue/compiler-core'
@@ -81,7 +84,10 @@ import { SchedulerJob } from './scheduler'
 import { LifecycleHooks } from './enums'
 
 export type Data = Record<string, unknown>
-
+// Whether the attrs option is defined
+export type HasDefinedAttrs<T> = Equal<keyof T, string> extends true
+  ? false
+  : true
 /**
  * For extending allowed non-declared props on components in TSX
  */
@@ -184,10 +190,13 @@ type LifecycleHook<TFn = Function> = TFn[] | null
 // use `E extends any` to force evaluating type to fix #2362
 export type SetupContext<
   E = EmitsOptions,
-  S extends SlotsType = {}
+  S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>
 > = E extends any
   ? {
-      attrs: Data
+      attrs: HasDefinedAttrs<Attrs> extends true
+        ? UnwrapAttrsType<NonNullable<Attrs>>
+        : Data
       slots: UnwrapSlotsType<S>
       emit: EmitFn<E>
       expose: (exposed?: Record<string, any>) => void
diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts
index de4d304448a..95d7bb6ddef 100644
--- a/packages/runtime-core/src/componentOptions.ts
+++ b/packages/runtime-core/src/componentOptions.ts
@@ -111,7 +111,8 @@ export interface ComponentOptionsBase<
   Defaults = {},
   I extends ComponentInjectOptions = {},
   II extends string = string,
-  S extends SlotsType = {}
+  S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>
 > extends LegacyOptions<Props, D, C, M, Mixin, Extends, I, II>,
     ComponentInternalOptions,
     ComponentCustomOptions {
@@ -126,7 +127,7 @@ export interface ComponentOptionsBase<
           >
         >
     >,
-    ctx: SetupContext<E, S>
+    ctx: SetupContext<E, S, Attrs>
   ) => Promise<RawBindings> | RawBindings | RenderFunction | void
   name?: string
   template?: string | object // can be a direct DOM node
@@ -141,6 +142,7 @@ export interface ComponentOptionsBase<
   inheritAttrs?: boolean
   emits?: (E | EE[]) & ThisType<void>
   slots?: S
+  attrs?: Attrs
   // TODO infer public instance type based on exposed keys
   expose?: string[]
   serverPrefetch?(): void | Promise<any>
@@ -224,6 +226,7 @@ export type ComponentOptionsWithoutProps<
   I extends ComponentInjectOptions = {},
   II extends string = string,
   S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>,
   PE = Props & EmitsToProps<E>
 > = ComponentOptionsBase<
   PE,
@@ -238,7 +241,8 @@ export type ComponentOptionsWithoutProps<
   {},
   I,
   II,
-  S
+  S,
+  Attrs
 > & {
   props?: undefined
 } & ThisType<
@@ -255,7 +259,8 @@ export type ComponentOptionsWithoutProps<
       {},
       false,
       I,
-      S
+      S,
+      Attrs
     >
   >
 
@@ -272,6 +277,7 @@ export type ComponentOptionsWithArrayProps<
   I extends ComponentInjectOptions = {},
   II extends string = string,
   S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>,
   Props = Prettify<Readonly<{ [key in PropNames]?: any } & EmitsToProps<E>>>
 > = ComponentOptionsBase<
   Props,
@@ -286,7 +292,8 @@ export type ComponentOptionsWithArrayProps<
   {},
   I,
   II,
-  S
+  S,
+  Attrs
 > & {
   props: PropNames[]
 } & ThisType<
@@ -303,7 +310,8 @@ export type ComponentOptionsWithArrayProps<
       {},
       false,
       I,
-      S
+      S,
+      Attrs
     >
   >
 
@@ -320,6 +328,7 @@ export type ComponentOptionsWithObjectProps<
   I extends ComponentInjectOptions = {},
   II extends string = string,
   S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>,
   Props = Prettify<Readonly<ExtractPropTypes<PropsOptions> & EmitsToProps<E>>>,
   Defaults = ExtractDefaultPropTypes<PropsOptions>
 > = ComponentOptionsBase<
@@ -335,7 +344,8 @@ export type ComponentOptionsWithObjectProps<
   Defaults,
   I,
   II,
-  S
+  S,
+  Attrs
 > & {
   props: PropsOptions & ThisType<void>
 } & ThisType<
@@ -352,7 +362,8 @@ export type ComponentOptionsWithObjectProps<
       Defaults,
       false,
       I,
-      S
+      S,
+      Attrs
     >
   >
 
@@ -406,6 +417,25 @@ export type ComponentOptionsMixin = ComponentOptionsBase<
   any
 >
 
+declare const AttrSymbol: unique symbol
+export type AttrsType<T extends Record<string, any> = Record<string, any>> = {
+  [AttrSymbol]?: T extends new () => { $props: infer P }
+    ? NonNullable<P>
+    : T extends (props: infer P, ...args: any) => any
+    ? P
+    : T
+}
+
+export type UnwrapAttrsType<
+  Attrs extends AttrsType,
+  T = NonNullable<Attrs[typeof AttrSymbol]>
+> = [keyof Attrs] extends [never]
+  ? Data
+  : Readonly<
+      Prettify<{
+        [K in keyof T]: T[K]
+      }>
+    >
 export type ComputedOptions = Record<
   string,
   ComputedGetter<any> | WritableComputedOptions<any>
@@ -423,6 +453,17 @@ export type ExtractComputedReturns<T extends any> = {
     : never
 }
 
+export type StrictUnwrapAttrsType<
+  Attrs extends Record<string, unknown>,
+  T = NonNullable<Attrs>
+> = [keyof T] extends [never]
+  ? Readonly<Record<string, unknown>>
+  : T extends new () => { $props: infer P }
+  ? Readonly<NonNullable<P>>
+  : T extends (props: infer P, ...args: any) => any
+  ? Readonly<NonNullable<P>>
+  : Readonly<T>
+
 export type ObjectWatchOptionItem = {
   handler: WatchCallback | string
 } & WatchOptions
diff --git a/packages/runtime-core/src/componentPublicInstance.ts b/packages/runtime-core/src/componentPublicInstance.ts
index 7b552c8f92a..16fc1ce7abb 100644
--- a/packages/runtime-core/src/componentPublicInstance.ts
+++ b/packages/runtime-core/src/componentPublicInstance.ts
@@ -1,6 +1,8 @@
 import {
+  AllowedComponentProps,
   ComponentInternalInstance,
   Data,
+  HasDefinedAttrs,
   getExposeProxy,
   isStatefulComponent
 } from './component'
@@ -37,7 +39,9 @@ import {
   shouldCacheAccess,
   MergedComponentOptionsOverride,
   InjectToObject,
-  ComponentInjectOptions
+  ComponentInjectOptions,
+  AttrsType,
+  UnwrapAttrsType
 } from './componentOptions'
 import { EmitsOptions, EmitFn } from './componentEmits'
 import { SlotsType, UnwrapSlotsType } from './componentSlots'
@@ -149,6 +153,7 @@ export type CreateComponentPublicInstance<
   MakeDefaultsOptional extends boolean = false,
   I extends ComponentInjectOptions = {},
   S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>,
   PublicMixin = IntersectionMixin<Mixin> & IntersectionMixin<Extends>,
   PublicP = UnwrapMixinsType<PublicMixin, 'P'> & EnsureNonVoid<P>,
   PublicB = UnwrapMixinsType<PublicMixin, 'B'> & EnsureNonVoid<B>,
@@ -182,10 +187,12 @@ export type CreateComponentPublicInstance<
     Defaults,
     {},
     string,
-    S
+    S,
+    Attrs
   >,
   I,
-  S
+  S,
+  Attrs
 >
 
 // public properties exposed on the proxy, which is used as the render context
@@ -202,14 +209,23 @@ export type ComponentPublicInstance<
   MakeDefaultsOptional extends boolean = false,
   Options = ComponentOptionsBase<any, any, any, any, any, any, any, any, any>,
   I extends ComponentInjectOptions = {},
-  S extends SlotsType = {}
+  S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>, // Attrs type extracted from attrs option
+  // AttrsProps type used for JSX validation of attrs
+  AttrsProps = HasDefinedAttrs<Attrs> extends true // if attrs is defined
+    ? Omit<UnwrapAttrsType<Attrs>, keyof (P & PublicProps)> // exclude props from attrs, for JSX validation
+    : {} // no JSX validation of attrs
 > = {
   $: ComponentInternalInstance
   $data: D
   $props: MakeDefaultsOptional extends true
-    ? Partial<Defaults> & Omit<Prettify<P> & PublicProps, keyof Defaults>
-    : Prettify<P> & PublicProps
-  $attrs: Data
+    ? Partial<Defaults> &
+        Omit<Prettify<P> & PublicProps, keyof Defaults> &
+        AttrsProps
+    : Prettify<P> & PublicProps & AttrsProps
+  $attrs: HasDefinedAttrs<Attrs> extends true
+    ? Readonly<AttrsProps & AllowedComponentProps>
+    : Data
   $refs: Data
   $slots: UnwrapSlotsType<S>
   $root: ComponentPublicInstance | null
diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts
index 85bd92e75b0..8cec27eb510 100644
--- a/packages/runtime-core/src/index.ts
+++ b/packages/runtime-core/src/index.ts
@@ -71,6 +71,7 @@ export {
   defineExpose,
   defineOptions,
   defineSlots,
+  defineAttrs,
   defineModel,
   withDefaults,
   useModel
@@ -246,7 +247,8 @@ export type {
   MethodOptions,
   ComputedOptions,
   RuntimeCompilerOptions,
-  ComponentInjectOptions
+  ComponentInjectOptions,
+  AttrsType
 } from './componentOptions'
 export type { EmitsOptions, ObjectEmitsOptions } from './componentEmits'
 export type {
diff --git a/packages/runtime-core/types/scriptSetupHelpers.d.ts b/packages/runtime-core/types/scriptSetupHelpers.d.ts
index a9ba734a53d..9224ebb9a5b 100644
--- a/packages/runtime-core/types/scriptSetupHelpers.d.ts
+++ b/packages/runtime-core/types/scriptSetupHelpers.d.ts
@@ -7,6 +7,7 @@ type _defineOptions = typeof defineOptions
 type _defineSlots = typeof defineSlots
 type _defineModel = typeof defineModel
 type _withDefaults = typeof withDefaults
+type _defineAttrs = typeof defineAttrs
 
 declare global {
   const defineProps: _defineProps
@@ -16,4 +17,5 @@ declare global {
   const defineSlots: _defineSlots
   const defineModel: _defineModel
   const withDefaults: _withDefaults
+  const defineAttrs: _defineAttrs
 }
diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts
index 6ccf02c4fa6..ea1af5e594c 100644
--- a/packages/runtime-dom/__tests__/customElement.spec.ts
+++ b/packages/runtime-dom/__tests__/customElement.spec.ts
@@ -1,4 +1,5 @@
 import {
+  AttrsType,
   defineAsyncComponent,
   defineComponent,
   defineCustomElement,
@@ -287,8 +288,9 @@ describe('defineCustomElement', () => {
 
   describe('attrs', () => {
     const E = defineCustomElement({
+      attrs: Object as AttrsType<{ foo: string }>,
       render() {
-        return [h('div', null, this.$attrs.foo as string)]
+        return [h('div', null, this.$attrs.foo)]
       }
     })
     customElements.define('my-el-attrs', E)
diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts
index 5662b0b535b..48be004f437 100644
--- a/packages/runtime-dom/src/apiCustomElement.ts
+++ b/packages/runtime-dom/src/apiCustomElement.ts
@@ -21,7 +21,8 @@ import {
   ConcreteComponent,
   ComponentOptions,
   ComponentInjectOptions,
-  SlotsType
+  SlotsType,
+  AttrsType
 } from '@vue/runtime-core'
 import { camelize, extend, hyphenate, isArray, toNumber } from '@vue/shared'
 import { hydrate, render } from '.'
@@ -54,7 +55,8 @@ export function defineCustomElement<
   EE extends string = string,
   I extends ComponentInjectOptions = {},
   II extends string = string,
-  S extends SlotsType = {}
+  S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>
 >(
   options: ComponentOptionsWithoutProps<
     Props,
@@ -68,7 +70,8 @@ export function defineCustomElement<
     EE,
     I,
     II,
-    S
+    S,
+    Attrs
   > & { styles?: string[] }
 ): VueElementConstructor<Props>
 
@@ -85,7 +88,8 @@ export function defineCustomElement<
   EE extends string = string,
   I extends ComponentInjectOptions = {},
   II extends string = string,
-  S extends SlotsType = {}
+  S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>
 >(
   options: ComponentOptionsWithArrayProps<
     PropNames,
@@ -99,7 +103,8 @@ export function defineCustomElement<
     EE,
     I,
     II,
-    S
+    S,
+    Attrs
   > & { styles?: string[] }
 ): VueElementConstructor<{ [K in PropNames]: any }>
 
@@ -116,7 +121,8 @@ export function defineCustomElement<
   EE extends string = string,
   I extends ComponentInjectOptions = {},
   II extends string = string,
-  S extends SlotsType = {}
+  S extends SlotsType = {},
+  Attrs extends AttrsType = Record<string, unknown>
 >(
   options: ComponentOptionsWithObjectProps<
     PropsOptions,
@@ -130,7 +136,8 @@ export function defineCustomElement<
     EE,
     I,
     II,
-    S
+    S,
+    Attrs
   > & { styles?: string[] }
 ): VueElementConstructor<ExtractPropTypes<PropsOptions>>
 
diff --git a/packages/shared/src/typeUtils.ts b/packages/shared/src/typeUtils.ts
index 1deb4729125..92790d9ec8f 100644
--- a/packages/shared/src/typeUtils.ts
+++ b/packages/shared/src/typeUtils.ts
@@ -21,3 +21,11 @@ export type Awaited<T> = T extends null | undefined
     ? Awaited<V> // recursively unwrap the value
     : never // the argument to `then` was not callable
   : T // non-object or non-thenable
+
+// Conditional returns can enforce identical types.
+// See here: https://github.com/Microsoft/TypeScript/issues/27024#issuecomment-421529650
+export type Equal<Left, Right> = (<U>() => U extends Left ? 1 : 0) extends <
+  U
+>() => U extends Right ? 1 : 0
+  ? true
+  : false