diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index edf14163e2..4365f6306d 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -18,6 +18,7 @@ Changelog
  * Auto-select the `StreamField` block when only one block type is declared (Sébastien Corbin)
  * Add support for more advanced Draftail customisation APIs (Thibaud Colas)
  * Add the ability to export snippets listing via `SnippetViewSet.list_export` (Sage Abdullah)
+ * Add support for adding HTML `attrs` on `FieldPanel`, `FieldRowPanel`, `MultiFieldPanel`, and others (Aman Pandey, Antoni Martyniuk, LB (Ben) Johnston)
  * Fix: Prevent choosers from failing when initial value is an unrecognised ID, e.g. when moving a page from a location where `parent_page_types` would disallow it (Dan Braghis)
  * Fix: Move comment notifications toggle to the comments side panel (Sage Abdullah)
  * Fix: Remove comment button on InlinePanel fields (Sage Abdullah)
diff --git a/docs/reference/pages/panels.md b/docs/reference/pages/panels.md
index 8da2a9352c..6cf2195da0 100644
--- a/docs/reference/pages/panels.md
+++ b/docs/reference/pages/panels.md
@@ -47,6 +47,10 @@ Here are some built-in panel types that you can use in your panel definitions. T
 
         By default, field values from ``StreamField`` or ``RichTextField`` are redacted to prevent rendering of potentially insecure HTML mid-form. You can change this behaviour for custom panel types by overriding ``Panel.format_value_for_display()``.
 
+    .. attribute:: FieldPanel.attrs (optional)
+
+        Allows a dictionary containing HTML attributes to be set on the rendered panel. If you assign a value of ``True`` or ``False`` to an attribute, it will be rendered as an HTML5 boolean attribute.
+
 ```
 
 ### MultiFieldPanel
@@ -63,6 +67,11 @@ Here are some built-in panel types that you can use in your panel definitions. T
     .. attribute:: MultiFieldPanel.permission (optional)
 
         Allows a panel to be selectively shown to users with sufficient permission. Accepts a permission codename such as ``'myapp.change_blog_category'`` - if the logged-in user does not have that permission, the panel will be omitted from the form. Similar to :attr:`FieldPanel.permission`.
+
+    .. attribute:: MultiFieldPanel.attrs (optional)
+
+        Allows a dictionary containing HTML attributes to be set on the rendered panel. If you assign a value of ``True`` or ``False`` to an attribute, it will be rendered as an HTML5 boolean attribute.
+
 ```
 
 (inline_panels)=
@@ -94,6 +103,10 @@ Here are some built-in panel types that you can use in your panel definitions. T
 
         Maximum number of forms a user must submit.
 
+    .. attribute:: InlinePanel.attrs (optional)
+
+        Allows a dictionary containing HTML attributes to be set on the rendered panel. If you assign a value of ``True`` or ``False`` to an attribute, it will be rendered as an HTML5 boolean attribute.
+
 ```
 
 (multiple_chooser_panel)=
@@ -150,6 +163,11 @@ The `MultipleChooserPanel` definition on `BlogPage` would be:
     .. attribute:: FieldRowPanel.permission (optional)
 
         Allows a panel to be selectively shown to users with sufficient permission. Accepts a permission codename such as ``'myapp.change_blog_category'`` - if the logged-in user does not have that permission, the panel will be omitted from the form. Similar to :attr:`FieldPanel.permission`.
+
+    .. attribute:: FieldRowPanel.attrs (optional)
+
+        Allows a dictionary containing HTML attributes to be set on the rendered panel. If you assign a value of ``True`` or ``False`` to an attribute, it will be rendered as an HTML5 boolean attribute.
+
 ```
 
 ### HelpPanel
@@ -164,6 +182,11 @@ The `MultipleChooserPanel` definition on `BlogPage` would be:
     .. attribute:: HelpPanel.template
 
         Path to a template rendering the full panel HTML.
+
+    .. attribute:: HelpPanel.attrs (optional)
+
+        Allows a dictionary containing HTML attributes to be set on the rendered panel. If you assign a value of ``True`` or ``False`` to an attribute, it will be rendered as an HTML5 boolean attribute.
+
 ```
 
 ### PageChooserPanel
@@ -323,3 +346,26 @@ To make input or chooser selection mandatory for a field, add [`blank=False`](dj
 ### Hiding fields
 
 Without a top-level panel definition, a `FieldPanel` will be constructed for each field in your model. If you intend to hide a field on the Wagtail page editor, define the field with [`editable=False`](django.db.models.Field.editable). If a field is not present in the panels definition, it will also be hidden.
+
+(panels_attrs)=
+
+### Additional HTML attributes
+
+Use the `attrs` parameter to add custom attributes to the HTML element of the panel. This allows you to specify additional attributes, such as `data-*` attributes. The `attrs` parameter accepts a dictionary where the keys are the attribute names and these will be rendered in the same way as Django's widget `attrs`[https://docs.djangoproject.com/en/stable/ref/forms/widgets/#django.forms.Widget.attrs] where `True` and `False will be treated as HTML5 boolean attributes.
+
+For example, you can use the `attrs` parameter to integrate your Stimulus controller to the panel:
+
+```python
+    content_panels = [
+        MultiFieldPanel(
+            [
+                FieldPanel('cover'),
+                FieldPanel('book_file'),
+                FieldPanel('publisher', attrs={'data-my-controller-target': 'myTarget'}),
+            ],
+            heading="Collection of Book Fields",
+            classname="collapsed",
+            attrs={'data-controller': 'my-controller'},
+        ),
+    ]
+```
diff --git a/docs/releases/5.1.md b/docs/releases/5.1.md
index 05ee58d703..26f4ff450a 100644
--- a/docs/releases/5.1.md
+++ b/docs/releases/5.1.md
@@ -42,6 +42,7 @@ Thank you to Damilola for his work, and to Google for sponsoring this project.
  * Auto-select the `StreamField` block when only one block type is declared (Sébastien Corbin)
  * Add support for more [advanced Draftail customisation APIs](extending_the_draftail_editor_advanced) (Thibaud Colas)
  * Add the ability to export snippets listing via `SnippetViewSet.list_export` (Sage Abdullah)
+ * Add support for adding [HTML `attrs`](panels_attrs) on `FieldPanel`, `FieldRowPanel`, `MultiFieldPanel`, and others (Aman Pandey, Antoni Martyniuk, LB (Ben) Johnston)
 
 ### Bug fixes
 
diff --git a/wagtail/admin/panels/base.py b/wagtail/admin/panels/base.py
index cb576ce528..003b15d20f 100644
--- a/wagtail/admin/panels/base.py
+++ b/wagtail/admin/panels/base.py
@@ -65,6 +65,7 @@ class Panel:
     :param help_text: Help text to display within the panel.
     :param base_form_class: The base form class to use for the panel. Defaults to the model's ``base_form_class``, before falling back to :class:`~wagtail.admin.forms.WagtailAdminModelForm`. This is only relevant for the top-level panel.
     :param icon: The name of the icon to display next to the panel heading.
+    :param attrs: A dictionary of HTML attributes to add to the panel's HTML element.
     """
 
     BASE_ATTRS = {}
diff --git a/wagtail/admin/templates/wagtailadmin/panels/multi_field_panel_child.html b/wagtail/admin/templates/wagtailadmin/panels/multi_field_panel_child.html
index 895bb8692c..07674fbe73 100644
--- a/wagtail/admin/templates/wagtailadmin/panels/multi_field_panel_child.html
+++ b/wagtail/admin/templates/wagtailadmin/panels/multi_field_panel_child.html
@@ -1,5 +1,5 @@
 {% load wagtailadmin_tags %}
-<div class="w-panel__wrapper {{ child.classes|join:' ' }}">
+<div class="{% classnames "w-panel__wrapper" child.classes|join:' ' %}" {% include "wagtailadmin/shared/attrs.html" with attrs=child.attrs %}>
     {% if child.heading %}
         {% fragment as label_content %}
             {{ child.heading }}{% if child.is_required %}<span class="w-required-mark">*</span>{% endif %}
diff --git a/wagtail/admin/templates/wagtailadmin/panels/object_list.html b/wagtail/admin/templates/wagtailadmin/panels/object_list.html
index 2fbed0fa3d..f21573a20b 100644
--- a/wagtail/admin/templates/wagtailadmin/panels/object_list.html
+++ b/wagtail/admin/templates/wagtailadmin/panels/object_list.html
@@ -1,11 +1,11 @@
 {% load wagtailadmin_tags %}
 
-<div class="w-form-width">
+<div class="w-form-width" {% include "wagtailadmin/shared/attrs.html" with attrs=self.attrs %}>
     {% if self.help_text %}
         {% help_block status="info" %}{{ self.help_text }}{% endhelp_block %}
     {% endif %}
     {% for child, identifier in self.visible_children_with_identifiers %}
-        {% panel id_prefix=self.prefix id=identifier classname=child.classes|join:' ' heading=child.heading heading_size="label" icon=child.icon id_for_label=child.id_for_label is_required=child.is_required %}
+        {% panel id_prefix=self.prefix id=identifier classname=child.classes|join:' ' attrs=child.attrs heading=child.heading heading_size="label" icon=child.icon id_for_label=child.id_for_label is_required=child.is_required %}
             {% component child %}
         {% endpanel %}
     {% endfor %}
diff --git a/wagtail/admin/templates/wagtailadmin/panels/tabbed_interface.html b/wagtail/admin/templates/wagtailadmin/panels/tabbed_interface.html
index c9655a5766..458e3893c7 100644
--- a/wagtail/admin/templates/wagtailadmin/panels/tabbed_interface.html
+++ b/wagtail/admin/templates/wagtailadmin/panels/tabbed_interface.html
@@ -4,7 +4,7 @@
     {% help_block status="info" %}{{ self.help_text }}{% endhelp_block %}
 {% endif %}
 
-<div class="w-tabs" data-tabs>
+<div class="w-tabs" data-tabs {% include "wagtailadmin/shared/attrs.html" with attrs=self.attrs %}>
     <div class="w-tabs__wrapper">
         <div role="tablist" class="w-tabs__list">
             {% for child, identifier in self.visible_children_with_identifiers %}
diff --git a/wagtail/admin/templates/wagtailadmin/shared/panel.html b/wagtail/admin/templates/wagtailadmin/shared/panel.html
index 4706cd4428..7f0bb24f2f 100644
--- a/wagtail/admin/templates/wagtailadmin/shared/panel.html
+++ b/wagtail/admin/templates/wagtailadmin/shared/panel.html
@@ -1,25 +1,26 @@
 {% load wagtailadmin_tags i18n %}
-{% comment %}
+{% comment "text/markdown" %}
     Variables this template accepts:
 
-    id_prefix - A prefix for all id attributes.
-    classname - String of CSS classes to use for the panel.
-    id - Unique to the page.
-    heading - The text of the panel’s heading.
-    heading_size - The size of the heading.
-    heading_level - ARIA override to the default heading level (2).
-    icon - Displayed alongside the heading.
-    id_for_label - id of an associated field.
-    is_required - If the panel contains a required field.
-    children - The panel’s contents.
-    header_controls - Additional panel buttons to display in the header area.
+    - `id_prefix` - A prefix for all id attributes.
+    - `classname` - String of CSS classes to use for the panel.
+    - `id` - Unique to the page.
+    - `heading` - The text of the panel’s heading.
+    - `heading_size` - The size of the heading.
+    - `heading_level` - ARIA override to the default heading level (2).
+    - `icon` - Displayed alongside the heading.
+    - `id_for_label` - id of an associated field.
+    - `is_required` - If the panel contains a required field.
+    - `children` - The panel’s contents.
+    - `header_controls` - Additional panel buttons to display in the header area.
+    - `attrs` - Additional HTML attributes to render on the panel.
 
 {% endcomment %}
 {% fragment as prefix %}{% if id_prefix %}{{ id_prefix }}-{% endif %}{{ id }}{% endfragment %}
 {% fragment as panel_id %}{{ prefix }}-section{% endfragment %}
 {% fragment as heading_id %}{{ prefix }}-heading{% endfragment %}
 {% fragment as content_id %}{{ prefix }}-content{% endfragment %}
-<section class="w-panel {{ classname }}" id="{{ panel_id }}" {% if heading %}aria-labelledby="{{ heading_id }}"{% endif %} data-panel>
+<section class="{% classnames "w-panel" classname %}" id="{{ panel_id }}" {% if heading %}aria-labelledby="{{ heading_id }}"{% endif %} data-panel {% include "wagtailadmin/shared/attrs.html" with attrs=attrs %}>
     {# If a panel has no heading nor header controls, we don’t want any of the associated UI. #}
     {% if heading or header_controls %}
         <div class="w-panel__header">
diff --git a/wagtail/admin/tests/test_edit_handlers.py b/wagtail/admin/tests/test_edit_handlers.py
index d070cee3a3..4965d4b5c5 100644
--- a/wagtail/admin/tests/test_edit_handlers.py
+++ b/wagtail/admin/tests/test_edit_handlers.py
@@ -20,6 +20,7 @@ from wagtail.admin.panels import (
     CommentPanel,
     FieldPanel,
     FieldRowPanel,
+    HelpPanel,
     InlinePanel,
     MultiFieldPanel,
     MultipleChooserPanel,
@@ -381,6 +382,115 @@ class TestExtractPanelDefinitionsFromModelClass(TestCase):
         )
 
 
+class TestPanelAttributes(WagtailTestUtils, TestCase):
+    def setUp(self):
+        self.request = RequestFactory().get("/")
+        user = self.create_superuser(username="admin")
+        self.request.user = user
+        self.user = self.login()
+
+        # a custom tabbed interface for EventPage
+        self.event_page_tabbed_interface = TabbedInterface(
+            [
+                ObjectList(
+                    [
+                        HelpPanel(
+                            "Double-check event details before submit.",
+                            attrs={"data-panel-type": "help"},
+                        ),
+                        FieldPanel("title", widget=forms.Textarea),
+                        FieldRowPanel(
+                            [
+                                FieldPanel("date_from"),
+                                FieldPanel(
+                                    "date_to", attrs={"data-panel-type": "field"}
+                                ),
+                            ],
+                            attrs={"data-panel-type": "field-row"},
+                        ),
+                    ],
+                    heading="Event details",
+                    classname="shiny",
+                    attrs={"data-panel-type": "object-list"},
+                ),
+                ObjectList(
+                    [
+                        InlinePanel(
+                            "speakers",
+                            label="Speakers",
+                            attrs={"data-panel-type": "inline"},
+                        ),
+                    ],
+                    heading="Speakers",
+                ),
+                ObjectList(
+                    [
+                        MultiFieldPanel(
+                            [
+                                HelpPanel(
+                                    "Double-check cost details before submit.",
+                                    attrs={"data-panel-type": "help-cost"},
+                                ),
+                                FieldPanel("cost"),
+                                FieldRowPanel(
+                                    [
+                                        FieldPanel("cost"),
+                                        FieldPanel(
+                                            "cost",
+                                            attrs={
+                                                "data-panel-type": "nested-object_list-multi_field-field_row-field"
+                                            },
+                                        ),
+                                    ],
+                                    attrs={
+                                        "data-panel-type": "nested-object_list-multi_field-field_row"
+                                    },
+                                ),
+                            ],
+                            attrs={"data-panel-type": "multi-field"},
+                        )
+                    ],
+                    heading="Secret",
+                ),
+            ],
+            attrs={"data-panel-type": "tabs"},
+        ).bind_to_model(EventPage)
+
+    def test_render(self):
+        EventPageForm = self.event_page_tabbed_interface.get_form_class()
+        event = EventPage(title="Abergavenny sheepdog trials")
+        form = EventPageForm(instance=event)
+
+        tabbed_interface = self.event_page_tabbed_interface.get_bound_panel(
+            instance=event,
+            form=form,
+            request=self.request,
+        )
+
+        result = tabbed_interface.render_html()
+
+        # result should contain custom data attributes assigned to panels
+        # each attribute should be rendered exactly once
+        self.assertEqual(result.count('data-panel-type="tabs"'), 1)
+        self.assertEqual(result.count('data-panel-type="multi-field"'), 1)
+        self.assertEqual(
+            result.count('data-panel-type="nested-object_list-multi_field-field_row"'),
+            1,
+        )
+        self.assertEqual(
+            result.count(
+                'data-panel-type="nested-object_list-multi_field-field_row-field"'
+            ),
+            1,
+        )
+        self.assertEqual(result.count('data-panel-type="help-cost"'), 1)
+        self.assertEqual(result.count('data-panel-type="inline"'), 1)
+        self.assertEqual(result.count('data-panel-type="object-list"'), 1)
+        self.assertEqual(result.count('data-panel-type="field-row"'), 1)
+        self.assertEqual(result.count('data-panel-type="field"'), 1)
+        self.assertEqual(result.count('data-panel-type="help"'), 1)
+
+
 class TestTabbedInterface(WagtailTestUtils, TestCase):
     def setUp(self):
         self.request = RequestFactory().get("/")