diff --git a/lib/ex_doc/formatter/epub/templates/module_template.eex b/lib/ex_doc/formatter/epub/templates/module_template.eex
index 2639baddd..2242051de 100644
--- a/lib/ex_doc/formatter/epub/templates/module_template.eex
+++ b/lib/ex_doc/formatter/epub/templates/module_template.eex
@@ -18,15 +18,20 @@
     <%= if summary != [] do %>
       <section id="summary" class="details-list">
         <h1 class="section-heading">Summary</h1>
-        <%= for {name, nodes} <- summary, do: H.summary_template(name, nodes) %>
+        <%= for group <- summary, do: H.summary_template(group.title, group.docs) %>
       </section>
     <% end %>
 
-    <%= for {name, nodes} <- summary, key = text_to_id(name) do %>
+    <%= for group <- summary, key = text_to_id(group.title) do %>
       <section id="<%= key %>" class="details-list">
-        <h1 class="section-heading"><%=h to_string(name) %></h1>
+        <h1 class="section-heading"><%=h to_string(group.title) %></h1>
+        <%= if doc = group.rendered_doc do %>
+          <div class="group-description" id="group-description-<%= key %>">
+            <%= H.link_group_headings(doc, key) %>
+          </div>
+        <% end %>
         <div class="<%= key %>-list">
-          <%= for node <- nodes, do: H.detail_template(node, module) %>
+          <%= for node <- group.docs, do: H.detail_template(node, module) %>
         </div>
       </section>
     <% end %>
diff --git a/lib/ex_doc/formatter/html.ex b/lib/ex_doc/formatter/html.ex
index bdc3420e8..e7584367c 100644
--- a/lib/ex_doc/formatter/html.ex
+++ b/lib/ex_doc/formatter/html.ex
@@ -93,27 +93,32 @@ defmodule ExDoc.Formatter.HTML do
             language: language
           ] ++ base
 
-        docs =
-          for child_node <- node.docs do
-            id = id(node, child_node)
-
-            autolink_opts =
-              autolink_opts ++
-                [
-                  id: id,
-                  line: child_node.doc_line,
-                  file: child_node.doc_file,
-                  current_kfa: {child_node.type, child_node.name, child_node.arity}
-                ]
-
-            specs = Enum.map(child_node.specs, &language.autolink_spec(&1, autolink_opts))
-            child_node = %{child_node | specs: specs}
-            render_doc(child_node, language, autolink_opts, opts)
+        docs_groups =
+          for group <- node.docs_groups do
+            docs =
+              for child_node <- group.docs do
+                id = id(node, child_node)
+
+                autolink_opts =
+                  autolink_opts ++
+                    [
+                      id: id,
+                      line: child_node.doc_line,
+                      file: child_node.doc_file,
+                      current_kfa: {child_node.type, child_node.name, child_node.arity}
+                    ]
+
+                specs = Enum.map(child_node.specs, &language.autolink_spec(&1, autolink_opts))
+                child_node = %{child_node | specs: specs}
+                render_doc(child_node, language, autolink_opts, opts)
+              end
+
+            %{render_doc(group, language, autolink_opts, opts) | docs: docs}
           end
 
         %{
           render_doc(node, language, [{:id, node.id} | autolink_opts], opts)
-          | docs: docs
+          | docs_groups: docs_groups
         }
       end,
       timeout: :infinity
diff --git a/lib/ex_doc/formatter/html/templates.ex b/lib/ex_doc/formatter/html/templates.ex
index 80d180548..de6897b9c 100644
--- a/lib/ex_doc/formatter/html/templates.ex
+++ b/lib/ex_doc/formatter/html/templates.ex
@@ -115,9 +115,9 @@ defmodule ExDoc.Formatter.HTML.Templates do
     {id, modules}
   end
 
-  defp sidebar_entries({group, nodes}) do
+  defp sidebar_entries(group) do
     nodes =
-      for node <- nodes do
+      for node <- group.docs do
         id =
           if "struct" in node.annotations do
             node.signature
@@ -134,7 +134,7 @@ defmodule ExDoc.Formatter.HTML.Templates do
         %{id: id, title: node.signature, anchor: URI.encode(node.id), deprecated: deprecated?}
       end
 
-    %{key: text_to_id(group), name: group, nodes: nodes}
+    %{key: text_to_id(group.title), name: group.title, nodes: nodes}
   end
 
   defp module_sections(%ExDoc.ModuleNode{rendered_doc: nil}), do: [sections: []]
@@ -167,10 +167,7 @@ defmodule ExDoc.Formatter.HTML.Templates do
     |> Enum.map(&%{id: &1, anchor: URI.encode(text_to_id(&1))})
   end
 
-  def module_summary(module_node) do
-    # TODO: Maybe it should be moved to retriever and it already returned grouped metadata
-    ExDoc.GroupMatcher.group_by(module_node.docs_groups, module_node.docs, & &1.group)
-  end
+  def module_summary(module_node), do: module_node.docs_groups
 
   defp favicon_path(%{favicon: nil}), do: nil
   defp favicon_path(%{favicon: favicon}), do: "assets/favicon#{Path.extname(favicon)}"
@@ -281,6 +278,10 @@ defmodule ExDoc.Formatter.HTML.Templates do
     link_headings(content, prefix <> "-")
   end
 
+  def link_group_headings(content, key) do
+    link_headings(content, "group-#{key}-")
+  end
+
   templates = [
     detail_template: [:node, :module],
     footer_template: [:config, :node],
diff --git a/lib/ex_doc/formatter/html/templates/module_template.eex b/lib/ex_doc/formatter/html/templates/module_template.eex
index e14af45bf..944b746d3 100644
--- a/lib/ex_doc/formatter/html/templates/module_template.eex
+++ b/lib/ex_doc/formatter/html/templates/module_template.eex
@@ -39,20 +39,25 @@
       </a>
       <span class="text">Summary</span>
     </h1>
-    <%= for {name, nodes} <- summary, do: summary_template(name, nodes) %>
+    <%= for group <- summary, do: summary_template(group.title, group.docs) %>
   </section>
 <% end %>
 
-<%= for {name, nodes} <- summary, key = text_to_id(name) do %>
+<%= for group <- summary, key = text_to_id(group.title) do %>
   <section id="<%= key %>" class="details-list">
     <h1 class="section-heading">
       <a class="hover-link" href="#<%= key %>">
         <i class="ri-link-m" aria-hidden="true"></i>
       </a>
-      <span class="text"><%= name %></span>
+      <span class="text"><%= group.title %></span>
     </h1>
+    <%= if doc = group.rendered_doc do %>
+      <div class="group-description" id="group-description-<%= key %>">
+        <%= link_group_headings(doc, key) %>
+      </div>
+    <% end %>
     <div class="<%= key %>-list">
-      <%= for node <- nodes, do: detail_template(node, module) %>
+      <%= for node <- group.docs, do: detail_template(node, module) %>
     </div>
   </section>
 <% end %>
diff --git a/lib/ex_doc/group_matcher.ex b/lib/ex_doc/group_matcher.ex
index 6cda47c61..1ba306f11 100644
--- a/lib/ex_doc/group_matcher.ex
+++ b/lib/ex_doc/group_matcher.ex
@@ -14,23 +14,6 @@ defmodule ExDoc.GroupMatcher do
     Enum.find_index(groups, fn {k, _v} -> k == group end) || -1
   end
 
-  @doc """
-  Group the following entries and while preserving the order in `groups`.
-  """
-  def group_by(groups, entries, by) do
-    entries = Enum.group_by(entries, by)
-
-    {groups, leftovers} =
-      Enum.flat_map_reduce(groups, entries, fn group, grouped_nodes ->
-        case Map.pop(grouped_nodes, group, []) do
-          {[], grouped_nodes} -> {[], grouped_nodes}
-          {entries, grouped_nodes} -> {[{group, entries}], grouped_nodes}
-        end
-      end)
-
-    groups ++ Enum.sort(leftovers)
-  end
-
   @doc """
   Finds a matching group for the given module name, id, and metadata.
   """
diff --git a/lib/ex_doc/nodes.ex b/lib/ex_doc/nodes.ex
index 068e9f848..38d9d5750 100644
--- a/lib/ex_doc/nodes.ex
+++ b/lib/ex_doc/nodes.ex
@@ -43,7 +43,7 @@ defmodule ExDoc.ModuleNode do
           moduledoc_file: String.t(),
           source_path: String.t() | nil,
           source_url: String.t() | nil,
-          docs_groups: [atom()],
+          docs_groups: [ExDoc.DocGroupNode.t()],
           docs: [ExDoc.DocNode.t()],
           typespecs: [ExDoc.DocNode.t()],
           type: atom(),
@@ -87,11 +87,23 @@ defmodule ExDoc.DocNode do
           rendered_doc: String.t() | nil,
           type: atom(),
           signature: String.t(),
-          specs: [ExDoc.Language.spec_ast()],
+          specs: [ExDoc.Language.spec_ast() | String.t()],
           annotations: [annotation()],
-          group: atom() | nil,
+          group: String.t() | nil,
           doc_file: String.t(),
           doc_line: non_neg_integer(),
           source_url: String.t() | nil
         }
 end
+
+defmodule ExDoc.DocGroupNode do
+  defstruct title: nil, description: nil, doc: nil, rendered_doc: nil, docs: []
+
+  @type t :: %__MODULE__{
+          title: String.t() | atom(),
+          description: String.t() | nil,
+          doc: ExDoc.DocAST.t() | nil,
+          rendered_doc: String.t() | nil,
+          docs: [ExDoc.DocNode.t()]
+        }
+end
diff --git a/lib/ex_doc/retriever.ex b/lib/ex_doc/retriever.ex
index 128b4a18d..5b308b38c 100644
--- a/lib/ex_doc/retriever.ex
+++ b/lib/ex_doc/retriever.ex
@@ -140,7 +140,18 @@ defmodule ExDoc.Retriever do
     group_for_doc = config.group_for_doc
     annotations_for_docs = config.annotations_for_docs
 
-    docs = get_docs(module_data, source, group_for_doc, annotations_for_docs)
+    {docs, nodes_groups} = get_docs(module_data, source, group_for_doc, annotations_for_docs)
+    docs = ExDoc.Utils.natural_sort_by(docs, &"#{&1.name}/#{&1.arity}")
+
+    moduledoc_groups = Map.get(metadata, :groups, [])
+
+    docs_groups =
+      get_docs_groups(
+        moduledoc_groups ++ config.docs_groups ++ module_data.default_groups,
+        nodes_groups,
+        docs
+      )
+
     metadata = Map.put(metadata, :kind, module_data.type)
     group = GroupMatcher.match_module(config.groups_for_modules, module, module_data.id, metadata)
     {nested_title, nested_context} = module_data.nesting_info || {nil, nil}
@@ -154,8 +165,8 @@ defmodule ExDoc.Retriever do
       module: module,
       type: module_data.type,
       deprecated: metadata[:deprecated],
-      docs_groups: config.docs_groups ++ module_data.default_groups,
-      docs: ExDoc.Utils.natural_sort_by(docs, &"#{&1.name}/#{&1.arity}"),
+      docs_groups: docs_groups,
+      docs: docs,
       doc_format: format,
       doc: doc,
       source_doc: source_doc,
@@ -189,13 +200,15 @@ defmodule ExDoc.Retriever do
   defp get_docs(module_data, source, group_for_doc, annotations_for_docs) do
     {:docs_v1, _, _, _, _, _, docs} = module_data.docs
 
-    nodes =
+    {nodes, groups} =
       for doc <- docs,
           doc_data = module_data.language.doc_data(doc, module_data) do
-        get_doc(doc, doc_data, module_data, source, group_for_doc, annotations_for_docs)
+        {_node, _group} =
+          get_doc(doc, doc_data, module_data, source, group_for_doc, annotations_for_docs)
       end
+      |> Enum.unzip()
 
-    filter_defaults(nodes)
+    {filter_defaults(nodes), groups}
   end
 
   defp get_doc(doc, doc_data, module_data, source, group_for_doc, annotations_for_docs) do
@@ -222,9 +235,9 @@ defmodule ExDoc.Retriever do
       (source_doc && doc_ast(content_type, source_doc, file: doc_file, line: doc_line + 1)) ||
         doc_data.doc_fallback.()
 
-    group = group_for_doc.(metadata) || doc_data.default_group
+    group = normalize_group(group_for_doc.(metadata) || doc_data.default_group)
 
-    %ExDoc.DocNode{
+    doc_node = %ExDoc.DocNode{
       id: doc_data.id_key <> nil_or_name(name, arity),
       name: name,
       arity: arity,
@@ -238,9 +251,11 @@ defmodule ExDoc.Retriever do
       specs: doc_data.specs,
       source_url: source_url,
       type: doc_data.type,
-      group: group,
+      group: group.title,
       annotations: annotations
     }
+
+    {doc_node, group}
   end
 
   defp get_defaults(_name, _arity, 0), do: []
@@ -261,6 +276,57 @@ defmodule ExDoc.Retriever do
     end)
   end
 
+  defp get_docs_groups(module_groups, nodes_groups, doc_nodes) do
+    module_groups = Enum.map(module_groups, &normalize_group/1)
+
+    # Doc nodes already have normalized groups
+    nodes_groups_descriptions = Map.new(nodes_groups, &{&1.title, &1.description})
+
+    normal_groups = module_groups ++ nodes_groups
+    nodes_by_group_title = Enum.group_by(doc_nodes, & &1.group)
+
+    {docs_groups, _} =
+      Enum.flat_map_reduce(normal_groups, %{}, fn
+        group, seen when is_map_key(seen, group.title) ->
+          {[], seen}
+
+        group, seen ->
+          seen = Map.put(seen, group.title, true)
+
+          case Map.get(nodes_by_group_title, group.title, []) do
+            [] ->
+              {[], seen}
+
+            child_nodes ->
+              group = finalize_group(group, child_nodes, nodes_groups_descriptions)
+              {[group], seen}
+          end
+      end)
+
+    docs_groups
+  end
+
+  defp finalize_group(group, doc_nodes, description_fallbacks) do
+    description =
+      case group.description do
+        nil -> Map.get(description_fallbacks, group.title)
+        text -> text
+      end
+
+    doc_ast =
+      case description do
+        nil -> nil
+        text -> doc_ast("text/markdown", %{"en" => text}, [])
+      end
+
+    %ExDoc.DocGroupNode{
+      title: group.title,
+      description: description,
+      doc: doc_ast,
+      docs: doc_nodes
+    }
+  end
+
   ## General helpers
 
   defp nil_or_name(name, arity) do
@@ -314,4 +380,19 @@ defmodule ExDoc.Retriever do
   defp source_link(%{url_pattern: url_pattern, relative_path: path}, line) do
     url_pattern.(path, line)
   end
+
+  defp normalize_group(group) do
+    case group do
+      %{title: title, description: description}
+      when is_binary(title) and (is_binary(description) or is_nil(description)) ->
+        %{group | title: title, description: description}
+
+      kw when is_list(kw) ->
+        true = Keyword.keyword?(kw)
+        %{title: to_string(Keyword.fetch!(kw, :title)), description: kw[:description]}
+
+      title when is_binary(title) when is_atom(title) ->
+        %{title: to_string(title), description: nil}
+    end
+  end
 end
diff --git a/test/ex_doc/formatter/epub/templates_test.exs b/test/ex_doc/formatter/epub/templates_test.exs
index 724ea3400..ff93d13e5 100644
--- a/test/ex_doc/formatter/epub/templates_test.exs
+++ b/test/ex_doc/formatter/epub/templates_test.exs
@@ -145,6 +145,42 @@ defmodule ExDoc.Formatter.EPUB.TemplatesTest do
       assert content =~ ~r{id="functions".*id="example_1/0"}ms
     end
 
+    test "outputs groups descriptions" do
+      content =
+        get_module_page([CompiledWithDocs],
+          group_for_doc: fn metadata ->
+            if metadata[:purpose] == :example do
+              [
+                title: "Example functions",
+                description: """
+                ### A section heading example
+
+                A content example.
+
+                See `example/1` or `example/2`.
+                A link to `flatten/1`.
+                """
+              ]
+            else
+              "Functions"
+            end
+          end
+        )
+
+      doc = LazyHTML.from_document(content)
+
+      assert Enum.count(doc["div.group-description"]) == 1
+      assert Enum.count(doc["#group-description-example-functions"]) == 1
+      assert Enum.count(doc["#group-description-example-functions h3"]) == 1
+      assert Enum.count(doc["#group-example-functions-a-section-heading-example"]) == 1
+      assert Enum.count(doc["#example-functions .group-description a[href='#example/1']"]) == 1
+      assert Enum.count(doc["#example-functions .group-description a[href='#example/2']"]) == 1
+      assert Enum.count(doc["#example-functions .group-description a[href='#flatten/1']"]) == 1
+
+      assert content =~ ~s[<span class="text">A section heading example</span>]
+      assert content =~ "<p>A content example.</p>"
+    end
+
     test "outputs summaries" do
       content = get_module_page([CompiledWithDocs])
 
diff --git a/test/ex_doc/formatter/html/templates_test.exs b/test/ex_doc/formatter/html/templates_test.exs
index f13e21260..d0b7c404a 100644
--- a/test/ex_doc/formatter/html/templates_test.exs
+++ b/test/ex_doc/formatter/html/templates_test.exs
@@ -469,6 +469,42 @@ defmodule ExDoc.Formatter.HTML.TemplatesTest do
       assert Enum.count(doc["#functions [id='example/2']"]) == 0
     end
 
+    test "outputs groups descriptions", context do
+      content =
+        get_module_page([CompiledWithDocs], context,
+          group_for_doc: fn metadata ->
+            if metadata[:purpose] == :example do
+              [
+                title: "Example functions",
+                description: """
+                ### A section heading example
+
+                A content example.
+
+                See `example/1` or `example/2`.
+                A link to `flatten/1`.
+                """
+              ]
+            else
+              "Functions"
+            end
+          end
+        )
+
+      doc = LazyHTML.from_document(content)
+
+      assert Enum.count(doc["div.group-description"]) == 1
+      assert Enum.count(doc["#group-description-example-functions"]) == 1
+      assert Enum.count(doc["#group-description-example-functions h3"]) == 1
+      assert Enum.count(doc["#group-example-functions-a-section-heading-example"]) == 1
+      assert Enum.count(doc["#example-functions .group-description a[href='#example/1']"]) == 1
+      assert Enum.count(doc["#example-functions .group-description a[href='#example/2']"]) == 1
+      assert Enum.count(doc["#example-functions .group-description a[href='#flatten/1']"]) == 1
+
+      assert content =~ ~s[<span class="text">A section heading example</span>]
+      assert content =~ "<p>A content example.</p>"
+    end
+
     test "outputs deprecation information", context do
       content = get_module_page([CompiledWithDocs], context)
 
diff --git a/test/ex_doc/group_matcher_test.exs b/test/ex_doc/group_matcher_test.exs
index 9f5832570..a73f4363d 100644
--- a/test/ex_doc/group_matcher_test.exs
+++ b/test/ex_doc/group_matcher_test.exs
@@ -2,16 +2,6 @@ defmodule ExDoc.GroupMatcherTest do
   use ExUnit.Case, async: true
   import ExDoc.GroupMatcher
 
-  describe "group_by" do
-    test "group by given data with leftovers" do
-      assert group_by([1, 3, 5], [%{key: 1}, %{key: 3}, %{key: 2}], & &1.key) == [
-               {1, [%{key: 1}]},
-               {3, [%{key: 3}]},
-               {2, [%{key: 2}]}
-             ]
-    end
-  end
-
   describe "module matching" do
     test "by atom names" do
       patterns = [
diff --git a/test/ex_doc/retriever/erlang_test.exs b/test/ex_doc/retriever/erlang_test.exs
index 671fb329f..56f10126f 100644
--- a/test/ex_doc/retriever/erlang_test.exs
+++ b/test/ex_doc/retriever/erlang_test.exs
@@ -59,7 +59,7 @@ defmodule ExDoc.Retriever.ErlangTest do
                moduledoc_line: 2,
                moduledoc_file: moduledoc_file,
                docs: [equiv_function2, function1, function2],
-               docs_groups: ["Types", "Callbacks", "Functions"],
+               docs_groups: [%{title: "Functions"}],
                group: nil,
                id: "mod",
                language: ExDoc.Language.Erlang,
@@ -156,7 +156,7 @@ defmodule ExDoc.Retriever.ErlangTest do
                moduledoc_line: 6,
                moduledoc_file: moduledoc_file,
                docs: [type, callback, function],
-               docs_groups: ["Types", "Callbacks", "Functions"],
+               docs_groups: [%{title: "Types"}, %{title: "Callbacks"}, %{title: "Functions"}],
                group: nil,
                id: "mod",
                language: ExDoc.Language.Erlang,
@@ -397,7 +397,7 @@ defmodule ExDoc.Retriever.ErlangTest do
         deprecated: nil,
         moduledoc_line: _,
         docs: [function1, function2],
-        docs_groups: ["Types", "Callbacks", "Functions"],
+        docs_groups: [%{title: "Functions"}],
         group: nil,
         id: "mod",
         language: ExDoc.Language.Erlang,
diff --git a/test/ex_doc/retriever_test.exs b/test/ex_doc/retriever_test.exs
index 80588962a..243726d33 100644
--- a/test/ex_doc/retriever_test.exs
+++ b/test/ex_doc/retriever_test.exs
@@ -108,6 +108,87 @@ defmodule ExDoc.RetrieverTest do
       assert %{id: "baz/0", group: "c"} = baz
     end
 
+    test "default_group_for_doc can return group description from @moduledoc", c do
+      elixirc(c, ~S"""
+      defmodule A do
+
+        @moduledoc groups: [
+          "c",
+          %{title: "b", description: "predefined b"}
+        ]
+
+        @doc test_group: "a"
+        @callback foo() :: :ok
+
+        @doc test_group: "b"
+        def bar(), do: :ok
+
+        @doc test_group: "c"
+        def baz(), do: :ok
+      end
+      """)
+
+      config = %ExDoc.Config{
+        group_for_doc: fn meta ->
+          case meta[:test_group] do
+            "a" -> [title: "a", description: "for a"]
+            "b" -> [title: "b", description: "ignored description"]
+            "c" -> [title: "c", description: "for c"]
+          end
+        end
+      }
+
+      {[mod], []} = Retriever.docs_from_modules([A], config)
+
+      assert [c, b, a] = mod.docs_groups
+
+      # Description returned by the function should override nil
+      assert %{title: "c", description: "for c"} = c
+
+      # Description returned by the function should not override a
+      # description from @moduledoc
+      assert %{title: "b", description: "predefined b"} = b
+
+      # Description returned by th function should define a description
+      # for leftover groups
+      assert %{title: "a", description: "for a"} = a
+
+      [bar, baz, foo] = mod.docs
+
+      assert %{id: "c:foo/0", group: "a"} = foo
+      assert %{id: "bar/0", group: "b"} = bar
+      assert %{id: "baz/0", group: "c"} = baz
+    end
+
+    test "function groups description use moduledoc :groups metadata", c do
+      elixirc(c, ~S"""
+      defmodule A do
+        @moduledoc groups: [
+          "c",
+          %{title: "b", description: "text for b"}
+        ]
+
+        @doc group: "a"
+        @callback foo() :: :ok
+
+        @doc group: "b"
+        def bar(), do: :ok
+
+        @doc group: "c"
+        def baz(), do: :ok
+      end
+      """)
+
+      config = %ExDoc.Config{}
+      {[mod], []} = Retriever.docs_from_modules([A], config)
+
+      assert [
+               %{description: nil, title: "c"},
+               %{description: "text for b", title: "b"},
+               %{description: nil, title: "a"}
+             ] = mod.docs_groups
+    end
+
     test "function annotations", c do
       elixirc(c, ~S"""
       defmodule A do