diff --git a/docs/conf.py b/docs/conf.py
index bad35114351..7998d81f1d9 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -61,6 +61,7 @@
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
 # ones.
 extensions = [
+    'collapse',
     'depfile',
     'hxtool',
     'kerneldoc',
diff --git a/docs/sphinx-static/theme_overrides.css b/docs/sphinx-static/theme_overrides.css
index 5ceb89eb9a8..2ba98d23bbd 100644
--- a/docs/sphinx-static/theme_overrides.css
+++ b/docs/sphinx-static/theme_overrides.css
@@ -296,3 +296,13 @@ dl.field-list > dt {
 dl.field-list > dd:not(:last-child) {
     padding-bottom: 1em;
 }
+
+dl.field-list > dd > details {
+    border-left: solid 5px #bcc6d2;
+}
+
+dl.field-list > dd > details > summary {
+    background-color: #eaedf1;
+    color: black;
+    padding-left: 0.75em;
+}
diff --git a/docs/sphinx/collapse.py b/docs/sphinx/collapse.py
new file mode 100644
index 00000000000..519f1f4b95b
--- /dev/null
+++ b/docs/sphinx/collapse.py
@@ -0,0 +1,200 @@
+"""
+Adds a collapsible section to an HTML page using a details_ element.
+
+.. _details: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details
+
+Modified (for formatting, vendoring and removing dependencies) from
+sphinx_toolbox.collapse, originally by Dominic Davis-Foster
+<dominic@davis-foster.co.uk>
+
+See https://github.com/sphinx-toolbox/sphinx-toolbox/tree/master
+
+"""
+
+#
+#  Copyright © 2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
+#
+#  Permission is hereby granted, free of charge, to any person obtaining a copy
+#  of this software and associated documentation files (the "Software"), to deal
+#  in the Software without restriction, including without limitation the rights
+#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+#  copies of the Software, and to permit persons to whom the Software is
+#  furnished to do so, subject to the following conditions:
+#
+#  The above copyright notice and this permission notice shall be
+#  included in all copies or substantial portions of the Software.
+#
+#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
+#  OR OTHER DEALINGS IN THE SOFTWARE.
+#
+
+# stdlib
+from typing import (
+    Any,
+    ClassVar,
+    Dict,
+    Optional,
+    Sequence,
+)
+
+# 3rd party
+from docutils import nodes
+from docutils.parsers.rst import directives
+from docutils.parsers.rst.roles import set_classes
+
+from sphinx.application import Sphinx
+from sphinx.util.docutils import SphinxDirective
+from sphinx.writers.html import HTMLTranslator
+
+
+__all__ = (
+    "CollapseDirective",
+    "CollapseNode",
+    "visit_collapse_node",
+    "depart_collapse_node",
+    "setup",
+)
+
+
+def flag(argument: Any) -> bool:
+    """
+    Check for a valid flag option (no argument) and return :py:obj:`True`.
+
+    Used in the ``option_spec`` of directives.
+
+    .. seealso::
+
+       :class:`docutils.parsers.rst.directives.flag`, which returns
+       :py:obj:`None` instead of :py:obj:`True`.
+
+    :raises: :exc:`ValueError` if an argument is given.
+    """
+    if argument and argument.strip():
+        raise ValueError(f"No argument is allowed; {argument!r} supplied")
+    else:
+        return True
+
+
+class CollapseDirective(SphinxDirective):
+    """
+    A Sphinx directive to add a collapsible section to an HTML page
+    using a details_ element.
+
+    .. _details:
+       https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details
+    """
+
+    final_argument_whitespace: ClassVar[bool] = True
+    has_content: ClassVar[bool] = True
+
+    # The label
+    required_arguments: ClassVar[int] = 1
+
+    option_spec = {
+        "class": directives.class_option,
+        "name": directives.unchanged,
+        "open": flag,
+    }
+
+    def run(self) -> Sequence[nodes.Node]:
+        """
+        Process the content of the directive.
+        """
+
+        set_classes(self.options)
+        self.assert_has_content()
+
+        text = "\n".join(self.content)
+        label = self.arguments[0]
+
+        collapse_node = CollapseNode(text, label, **self.options)
+
+        self.add_name(collapse_node)
+
+        collapse_node["classes"].append(f"summary-{nodes.make_id(label)}")
+
+        self.state.nested_parse(
+            self.content, self.content_offset, collapse_node
+        )
+
+        return [collapse_node]
+
+
+class CollapseNode(nodes.Body, nodes.Element):
+    """
+    Node that represents a collapsible section.
+
+    :param rawsource:
+    :param label:
+    """
+
+    def __init__(
+        self,
+        rawsource: str = "",
+        label: Optional[str] = None,
+        *children: Any,
+        **attributes: Any,
+    ) -> None:
+        super().__init__(rawsource, *children, **attributes)
+        self.label = label
+
+
+def visit_collapse_node(translator: HTMLTranslator, node: CollapseNode) -> None:
+    """
+    Visit a :class:`~.CollapseNode`.
+
+    :param translator:
+    :param node: The node being visited.
+    """
+
+    tag_parts = ["details"]
+
+    if names := node.get("names", None):
+        tag_parts.append(f'name="{" ".join(names)}"')
+
+    if classes := node.get("classes", None):
+        tag_parts.append(f'class="{" ".join(classes)}"')
+
+    if node.attributes.get("open", False):
+        tag_parts.append("open")
+
+    translator.body.append(
+        f"<{' '.join(tag_parts)}>\n<summary>{node.label}</summary>"
+    )
+    translator.context.append("</details>")
+
+
+def depart_collapse_node(
+    translator: HTMLTranslator, node: CollapseNode
+) -> None:
+    """
+    Depart a :class:`~.CollapseNode`.
+
+    :param translator:
+    :param node: The node being visited.
+    """
+    translator.body.append(translator.context.pop())
+
+
+def setup(app: Sphinx) -> Dict[str, Any]:
+    """
+    Setup :mod:`sphinx_toolbox.collapse`.
+
+    :param app: The Sphinx application.
+    """
+    app.add_directive("collapse", CollapseDirective)
+    app.add_node(
+        CollapseNode,
+        html=(visit_collapse_node, depart_collapse_node),
+        latex=(lambda *args, **kwargs: None, lambda *args, **kwargs: None),
+    )
+
+    return {
+        "parallel_read_safe": True,
+        "version": "3.5.0",
+    }
diff --git a/docs/sphinx/qapi-domain.py b/docs/sphinx/qapi-domain.py
index 7cbf12d93f7..ee9b1d056ff 100644
--- a/docs/sphinx/qapi-domain.py
+++ b/docs/sphinx/qapi-domain.py
@@ -23,6 +23,7 @@
 from docutils.parsers.rst import directives
 from docutils.statemachine import StringList
 
+from collapse import CollapseNode
 from compat import keyword_node, nested_parse, space_node
 import sphinx
 from sphinx import addnodes
@@ -466,10 +467,7 @@ def _validate_field(self, field: nodes.field) -> None:
         allowed_fields = set(self.env.app.config.qapi_allowed_fields)
 
         field_label = name.astext()
-        if (
-            re.match(r"\[\S+ = \S+\]", field_label)
-            or field_label in allowed_fields
-        ):
+        if field_label == ":BRANCH:" or field_label in allowed_fields:
             # okie-dokey. branch entry or known good allowed name.
             return
 
@@ -528,6 +526,8 @@ def before_content(self) -> None:
             self.content_offset = 0
 
     def transform_content(self, contentnode: addnodes.desc_content) -> None:
+        self.content_node = contentnode
+
         # Sphinx workaround: Inject our parsed content and restore state.
         if self._temp_node:
             contentnode += self._temp_node.children
@@ -547,6 +547,66 @@ def transform_content(self, contentnode: addnodes.desc_content) -> None:
                     assert isinstance(field, nodes.field)
                     self._validate_field(field)
 
+    def after_content(self) -> None:
+        # Now that the DocFieldTransformer has been invoked in
+        # ObjectDescription.run, we can take our branch entries and
+        # extract their contents and inject them into the preceding
+        # field list body.
+
+        # For example:
+        #
+        # Arguments: * lorem
+        #            * ipsum
+        # :BRANCH:   <branch stuff here>
+        #
+        # will be transformed into:
+        #
+        # Arguments: * lorem
+        #            * ipsum
+        #            <branch stuff here>
+
+        branch_content: List[nodes.Node] = []
+        insertion_field: Optional[nodes.field] = None
+
+        def _inject(
+            field: Optional[nodes.field], content: List[nodes.Node]
+        ) -> None:
+            if not (field or content):
+                return
+            if not field:
+                print(
+                    "ERROR: qapi:branch directive used without a preceding "
+                    "Members/Arguments field; there's nowhere to inject the "
+                    "branch members into!"
+                )
+                return
+            _, body = _unpack_field(field)
+            body += content
+
+        for child in self.content_node:
+            if isinstance(child, nodes.field_list):
+                delete_queue: List[nodes.field] = []
+                for field in child.children:
+                    assert isinstance(field, nodes.field)
+                    name, body = _unpack_field(field)
+                    if name.astext() == ":BRANCH:":
+                        branch_content.extend(body.children)
+                        delete_queue.append(field)
+                    elif not branch_content:
+                        insertion_field = field
+                    else:
+                        # Field is not a branch and branch_content is not empty;
+                        # we should do the insertion here and now.
+                        _inject(insertion_field, branch_content)
+                        insertion_field = None
+                        branch_content = []
+
+                # Delete any branches encountered thus far.
+                for field in delete_queue:
+                    child.remove(field)
+
+        _inject(insertion_field, branch_content)
+
     def _toc_entry_name(self, sig_node: desc_signature) -> str:
         # This controls the name in the TOC and on the sidebar.
 
@@ -770,14 +830,27 @@ def run(self) -> List[Node]:
 
 class Branch(SphinxDirective):
     """
-    Nested directive which only serves to introduce temporary
-    metadata but return its parsed content nodes unaltered otherwise.
+    A nested directive to document union Branches.
 
-    Technically, you can put whatever you want in here, but doing so may
-    prevent proper merging of adjacent field lists.
+    This directive should contain at most one type of semantic/grouped
+    field list type, either "memb" or "arg".
     """
 
-    doc_field_types: List[Field] = []
+    # The :BRANCH: name is a placeholder. You can probably get a
+    # legitimate field list with this name if you try hard
+    # enough, but it should be difficult to do by accident.
+    doc_field_types: List[Field] = [
+        # :arg type name: descr
+        # :memb type name: descr
+        QAPITypedField(
+            "branch-arg-or-memb",
+            label=":BRANCH:",
+            names=("arg", "memb"),
+            typerolename="type",
+            can_collapse=False,
+        ),
+    ]
+
     has_content = True
     required_arguments = 2
     optional_arguments = 0
@@ -799,29 +872,59 @@ def run(self) -> list[Node]:
         discrim = self.arguments[0].strip()
         value = self.arguments[1].strip()
 
-        # The label name is dynamically generated per-instance instead
-        # of per-class to incorporate the branch conditions as a label
-        # name.
-        self.doc_field_types = [
-            # :arg type name: descr
-            # :memb type name: descr
-            QAPITypedField(
-                "branch-arg-or-memb",
-                label=f"[{discrim} = {value}]",
-                # In a branch, we don't actually use the name of the
-                # field name to generate the label; so allow either-or.
-                names=("arg", "memb"),
-                typerolename="type",
-            ),
-        ]
-
-        content_node: addnodes.desc_content = addnodes.desc_content()
+        content_node = addnodes.desc_content()
         nested_parse(self, content_node)
         # DocFieldTransformer usually expects ObjectDescription, but... quack!
         transformer = DocFieldTransformer(quack(ObjectDescription, self))
         transformer.transform_all(content_node)
 
-        return content_node.children
+        if not content_node.children:
+            # Empty branch - it happens. Squelch it.
+            return []
+
+        # Now, we're gonna do some surgery.
+        #
+        # We're going to find any field lists that contain members/args
+        # and extract the transformed content from that field list,
+        # while deleting the field list itself - to avoid having nested
+        # field lists for branches.
+
+        replacements = []
+        for child in content_node:
+            if isinstance(child, nodes.field_list):
+                if len(child.children) != 1:
+                    # We're only interested in field lists with one field;
+                    # since these are the semantically grouped/formatted bits.
+                    continue
+
+                field = child.children[0]
+                assert isinstance(field, nodes.field)
+                field_name, field_body = _unpack_field(field)
+
+                if field_name.astext() == ":BRANCH:":
+                    replacements.append((child, field_body))
+
+        # Delete grouped field lists, replacing them with just their content;
+        # after field transformation, this should be a list.
+        for child, field_body in replacements:
+            child.replace_self(field_body.children)
+
+        # Wrap the entire contents up in a collapsible node
+        collapse_node = CollapseNode(
+            "", f"When {discrim} is {value}: ...", *content_node.children
+        )
+
+        # Then wrap it all back up in a new field list.
+        new_content = nodes.field_list(
+            "",
+            nodes.field(
+                "",
+                nodes.field_name("", ":BRANCH:"),
+                nodes.field_body("", collapse_node),
+            ),
+        )
+
+        return [new_content]
 
 
 class QAPIIndex(Index):
