diff --git a/app/controllers/course/wiki.php b/app/controllers/course/wiki.php
index 8e72f8a7d96b7e508689772cdddbf2e95f445b99..926f641fe4fbd61fe13bfc6d470daa71f3d5e6f6 100644
--- a/app/controllers/course/wiki.php
+++ b/app/controllers/course/wiki.php
@@ -30,7 +30,7 @@ class Course_WikiController extends AuthenticatedController
         }
         Navigation::activateItem('/course/wiki/start');
 
-        $this->page = new WikiPage($page_id);
+        $this->page = WikiPage::find($page_id) ?: new WikiPage();
         $this->validateWikiPage($this->page, $this->range);
 
         $sidebar = Sidebar::Get();
@@ -97,6 +97,13 @@ class Course_WikiController extends AuthenticatedController
             $sidebar->addWidget($actions);
         }
 
+        $contentbarProps = [
+            'icon' => 'wiki',
+            'isContentBar' => true
+        ];
+
+        $toc = CoreWiki::getTOC($this->page);
+
         if (!$this->page->isNew()) {
             //then the wiki is not empty
             $search = new SearchWidget($this->searchURL());
@@ -116,12 +123,18 @@ class Course_WikiController extends AuthenticatedController
                 Icon::create('file-pdf')
             );
             $sidebar->addWidget($exports);
+
+            $contentbarProps['toc'] = $toc;
         }
 
-        $startPage = WikiPage::find($this->range->getConfiguration()->WIKI_STARTPAGE_ID);
-        $this->contentbar = ContentBar::get()
-            ->setTOC(CoreWiki::getTOC($this->page))
-            ->setIcon(Icon::create('wiki'));
+        // Content bar
+        $this->contentBarVueApp = \Studip\VueApp::create('ContentBar')
+            ->withProps($contentbarProps)
+            ->withComponent('ContentBarBreadcrumbs')
+            ->withSlot('breadcrumb-list',
+                sprintf("<content-bar-breadcrumbs :toc='%s'/>", json_encode($toc))
+            );
+
         if (!$this->page->isNew()) {
             $author = _('unbekannt');
             if ($this->page->user) {
@@ -132,8 +145,8 @@ class Course_WikiController extends AuthenticatedController
                 );
             }
 
-            $this->contentbar->setInfoHTML(sprintf(
-                _('Version %1$s, geändert von %2$s <br> am %3$s'),
+            $this->contentBarVueApp = $this->contentBarVueApp->withSlot('info-text', sprintf(
+                _('Version %1$s, geändert von %2$s am %3$s'),
                 $this->page->versionnumber,
                 $author,
                 date('d.m.Y H:i:s', $this->page['chdate'])
@@ -167,13 +180,7 @@ class Course_WikiController extends AuthenticatedController
                     );
                 }
             }
-            $action_menu->addLink(
-                '#',
-                _('Als Vollbild anzeigen'),
-                Icon::create('screen-full'),
-                ['class' => 'fullscreen-trigger hidden-medium-down']
-            );
-            $this->contentbar->setActionMenu($action_menu);
+            $this->contentBarVueApp = $this->contentBarVueApp->withSlot('menu', $action_menu->render());
         }
 
     }
@@ -478,10 +485,6 @@ class Course_WikiController extends AuthenticatedController
              ORDER BY Nachname, Vorname",
             [$page->id]
         );
-        $this->contentbar = ContentBar::get()
-            ->setTOC(CoreWiki::getTOC($page))
-            ->setIcon(Icon::create('wiki'))
-            ->setInfoHTML(_('Zuletzt gespeichert') .': '. '<span class="wiki-last-edited-' . $this->page->id . '"></span>');
     }
 
     public function apply_editing_action(WikiPage $page)
@@ -715,15 +718,24 @@ class Course_WikiController extends AuthenticatedController
 
         Navigation::activateItem('/course/wiki/start');
         Sidebar::Get()->addWidget($this->getViewsWidget($version->page, 'history'));
-        $startPage = WikiPage::find($this->range->getConfiguration()->WIKI_STARTPAGE_ID);
-        $this->contentbar = ContentBar::get()
-            ->setTOC(CoreWiki::getTOC($version->page))
-            ->setIcon(Icon::create('wiki'))
-            ->setInfoHTML(sprintf(
+
+        $toc = CoreWiki::getTOC($version->page);
+        $this->contentBarVueApp = \Studip\VueApp::create('ContentBar')
+            ->withProps([
+                'icon' => 'wiki',
+                'isContentBar' => true,
+                'toc' => $toc,
+            ])
+            ->withSlot('info-text', sprintf(
                 _('Version %1$s vom %2$s'),
                 $version->versionnumber,
                 date('d.m.Y H:i:s', $version['mkdate'])
-            ));
+            ))
+            ->withComponent('ContentBarBreadcrumbs')
+            ->withSlot('breadcrumb-list', sprintf(
+                "<content-bar-breadcrumbs :toc='%s'/>",
+                json_encode($toc))
+            );
     }
 
     public function blame_action(WikiPage $page)
diff --git a/app/controllers/oer/market.php b/app/controllers/oer/market.php
index 72133d7e60777d2f1e7c3a5d6487a035f2145a10..69030c2e93ffbad6ab31132f8d0ee2490a5aa5df 100644
--- a/app/controllers/oer/market.php
+++ b/app/controllers/oer/market.php
@@ -328,10 +328,11 @@ class Oer_MarketController extends StudipController
             }
         }
 
-        $this->contentbar = ContentBar::get()
-            ->setTOC(new TOCItem($this->material['name']))
-            ->setInfoHTML(htmlReady($infotext))
-            ->setIcon(Icon::create('oer-campus'));
+        $this->contentBarVueApp = \Studip\VueApp::create('ContentBar')->withProps([
+            'title' => $this->material['name'],
+            'icon' => 'oer-campus',
+            'isContentBar' => true,
+        ])->withSlot('info-text', htmlReady($infotext));
     }
 
     public function embed_action($material_id)
diff --git a/app/views/course/wiki/edit.php b/app/views/course/wiki/edit.php
index 983a02cce132b237feeb757c5568e360502a3d27..40e01114776b0d33d6e0fbdc46ad6802b736d180 100644
--- a/app/views/course/wiki/edit.php
+++ b/app/views/course/wiki/edit.php
@@ -3,12 +3,9 @@
  * @var WikiPage $page
  * @var Course_WikiController $controller
  * @var WikiOnlineEditingUser $me_online
- * @var ContentBar $contentbar
  */
 ?>
 
-<?= $contentbar ?>
-
 <?= Studip\VueApp::create('WikiEditor')
     ->withProps([
         'cancel-url'   => $controller->leave_editingURL($page),
@@ -18,5 +15,6 @@
         'page-id'      => (int) $page->id,
         'save-url'     => $controller->saveURL($page),
         'users'        => $page->getOnlineUsers(),
+        'toc'          => CoreWiki::getTOC($page),
     ])
 ?>
diff --git a/app/views/course/wiki/page.php b/app/views/course/wiki/page.php
index e3c67cfbf3474e6d08f6cbc5e10ef66f7a1c36cd..21ae0317562cb832cd6ded58da4b85f3e58f4126 100644
--- a/app/views/course/wiki/page.php
+++ b/app/views/course/wiki/page.php
@@ -4,10 +4,11 @@
  * @var string $edit_perms
  * @var Context $range
  * @var Course_WikiController $controller
+ * @var \Studip\VueApp $contentBarVueApp
  */
 
-echo $contentbar;
 ?>
+<?= $contentBarVueApp->render() ?>
 
 <? if ($page->isEditable()) : ?>
 <form action="<?= $controller->delete($page->id) ?>" method="post" id="delete_page">
diff --git a/app/views/course/wiki/version.php b/app/views/course/wiki/version.php
index 26094ae6161e598e08bf6b8adbf3db70d1500778..177d7d0e0d1e7b9636444efdcb5b75eafa9f3b33 100644
--- a/app/views/course/wiki/version.php
+++ b/app/views/course/wiki/version.php
@@ -1,4 +1,10 @@
-<?= $contentbar ?>
+<?php
+/**
+ * @var \Studip\VueApp $contentBarVueApp
+ */
+?>
+
+<?= $contentBarVueApp->render() ?>
 
 <div class="wiki_page_content">
     <?= wikiReady($version['content']) ?>
diff --git a/app/views/oer/market/details.php b/app/views/oer/market/details.php
index 9e1c60ba368e95dcd137a681140106fd8158aa22..c05bbdfceb2b1c322d7795e8b04251821cd9465c 100644
--- a/app/views/oer/market/details.php
+++ b/app/views/oer/market/details.php
@@ -1,4 +1,9 @@
-<?= $contentbar ?>
+<?php
+/**
+ * @var Studip\VueApp $contentBarVueApp
+ */
+?>
+<?= $contentBarVueApp->render() ?>
 
 <? $url = $material->getDownloadUrl() ?>
 
diff --git a/lib/classes/Debug/VueCollector.php b/lib/classes/Debug/VueCollector.php
index a2d90f3ab540ca5de88dc5e1938ca7fccc15b310..0dd479b447146313be192ee11cfc0d5a1c5ea73a 100644
--- a/lib/classes/Debug/VueCollector.php
+++ b/lib/classes/Debug/VueCollector.php
@@ -45,6 +45,23 @@ final class VueCollector extends DataCollector implements Renderable
             }
         }
 
+        $slots = $this->app->getSlots();
+        if (count($slots) > 0) {
+            ksort($slots);
+
+            $data['== SLOTS =='] = count($slots) . ' items';
+            foreach ($slots as $key => $value) {
+                $data[$key] = $this->dumpVar($value);
+            }
+        }
+
+        $components = $this->app->getComponents();
+        ksort($components);
+        $data['== COMPONENTS =='] = count($components) . ' items';
+        foreach ($components as $value) {
+            $data[$value] = '';
+        }
+
         return $data;
     }
 
diff --git a/lib/classes/Icon.php b/lib/classes/Icon.php
index 6c586a03d29d8a9e96574c2ec9961f561d14d4cb..fd2a25b5e9aedb217c0de2ac703e873422a91115 100644
--- a/lib/classes/Icon.php
+++ b/lib/classes/Icon.php
@@ -9,7 +9,7 @@
  * @license   GPL2 or any later version
  * @since     3.2
  */
-class Icon
+class Icon implements JsonSerializable
 {
     const SVG = 1;
     const CSS_BACKGROUND = 4;
@@ -196,6 +196,11 @@ class Icon
         return $this->asImg();
     }
 
+    public function jsonSerialize(): mixed
+    {
+        return get_object_vars($this);
+    }
+
     /**
      * Renders the icon inside an img html tag.
      *
diff --git a/lib/classes/VueApp.php b/lib/classes/VueApp.php
index 76c9a52ae3d171199db2679bd3c820d469fa65ca..c26b0111163d3bde4d019ad6460714e6aa5843b4 100644
--- a/lib/classes/VueApp.php
+++ b/lib/classes/VueApp.php
@@ -43,6 +43,7 @@ final class VueApp implements Stringable
     private array $slots = [];
     private array $stores = [];
     private array $storeData = [];
+    private array $components = [];
 
     /**
      * Private constructor since we want to enforce the use of VueApp::create().
@@ -50,6 +51,7 @@ final class VueApp implements Stringable
     private function __construct(
         private readonly string $base_component
     ) {
+        $this->components[] = $base_component;
     }
 
     /**
@@ -152,13 +154,31 @@ final class VueApp implements Stringable
         return $this->plugins;
     }
 
+    /**
+     * Registers a component for use e.g. in slots.
+     */
+    public function withComponent(string $component): VueApp
+    {
+        $clone = clone $this;
+        $clone->components[] = $component;
+        return $clone;
+    }
+
+    /**
+     * Returns all components
+     */
+    public function getComponents(): array
+    {
+        return $this->components;
+    }
+
     /**
      * Returns the template to render the vue app
      */
     public function getTemplate(): Template
     {
         $data = [
-            'components' => [$this->base_component],
+            'components' => $this->components,
         ];
 
         if (count($this->stores) > 0) {
@@ -174,6 +194,7 @@ final class VueApp implements Stringable
         $template->attributes = ['data-vue-app' => json_encode($data)];
         $template->props = $this->getPreparedProps();
         $template->storeData = $this->storeData;
+        $template->slots = $this->getSlots();
         return $template;
     }
 
diff --git a/lib/models/WikiPage.php b/lib/models/WikiPage.php
index 817dfaec0b2bd1330ffb17889f3c2930bab9a104..bb41d1b19e7830fb43a4a01cfa46ecfa2c5c6531 100644
--- a/lib/models/WikiPage.php
+++ b/lib/models/WikiPage.php
@@ -201,32 +201,6 @@ class WikiPage extends SimpleORMap implements PrivacyObject
     }
 
 
-    /**
-     * Returns the start page of a wiki for a given course. The start page has
-     * the keyword 'WikiWikiWeb'.
-     *
-     * @param  string $range_id Course id
-     * @return WikiPage
-     */
-    public static function getStartPage($range_id): WikiPage
-    {
-        $page_id = CourseConfig::get($range_id)->WIKI_STARTPAGE_ID;
-
-        if ($page_id) {
-            return self::find($page_id);
-        }
-
-        $page = new WikiPage();
-        $page->setValue('content', _('Dieses Wiki ist noch leer.'));
-        if ($page->isEditable()) {
-            $page->setValue(
-                'content',
-                $page->getValue('content') .  ' ' . _("Bearbeiten Sie es!\nNeue Seiten oder Links werden einfach durch Eingeben von [nop][[Wikinamen]][/nop] in doppelten eckigen Klammern angelegt.")
-            );
-        }
-        return $page;
-    }
-
     /**
      * Export available data of a given user into a storage object
      * (an instance of the StoredUserData class) for that user.
diff --git a/lib/modules/CoreWiki.php b/lib/modules/CoreWiki.php
index a447451b8e34a9104e0e79e99ed3191718b204a0..b544af3f54fa9c16ed9ba86965a51562fd3e76cc 100644
--- a/lib/modules/CoreWiki.php
+++ b/lib/modules/CoreWiki.php
@@ -178,28 +178,45 @@ class CoreWiki extends CorePlugin implements StudipModule
 
 
     /**
-     * Generates a page hierarchy for table of contents/breadcrumbs.
-     * @return TOCItem
+     * Generates a TOCItem tree containing all pages in the currently opened wiki
+     * for use in table of contents/breadcrumbs.
+     * To prevent cyclic data references, the TOCItems in the tree do not contain
+     * references to their parent pages.
+     * This allows the resultant TOCItem to be serialized via json_decode for
+     * use in Vue.
+     *
+     * @param $activePage WikiPage The page that the user has currently navigated to.
+     * @return TOCItem A TOCItem for the root of the wiki and all of its descendants.
      */
-    public static function getTOC($page, $first = true): TOCItem
+    public static function getTOC(WikiPage $activePage): TOCItem
     {
-        $root = new TOCItem(
-            ($page && ($page->isNew() || $page->name === 'WikiWikiWeb'))
-            ? _('Wiki-Startseite')
-            : $page->name
-        );
-        $root->setURL(URLHelper::getURL('dispatch.php/course/wiki/page/'.$page->id));
-        if ($page->name == 'WikiWikiWeb' || $page->id == CourseConfig::get($page->range_id)->WIKI_STARTPAGE_ID) {
-            $root->setIcon(Icon::create('wiki'));
-        }
-        $root->setActive($first);
+        $rootId = CourseConfig::get(Context::getId())->WIKI_STARTPAGE_ID;
+        $rootPage = WikiPage::find($rootId) ?: $activePage;
 
-        if ($page->parent) {
-            $parent = self::getTOC($page->parent, false);
-            $root->setParent($parent);
-        }
+        $rootToc = self::getTOCRecursive($rootPage, $activePage->page_id);
+        $rootToc->setTitle(_('Wiki-Startseite'));
+        $rootToc->setIcon(Icon::create('wiki'));
+        return $rootToc;
+    }
 
-        return $root;
+    /**
+     * Using a recursive depth-first traversal of the wiki page hierarchy,
+     * create a TOCItem tree starting at the given $page.
+     *
+     * @param WikiPage $page The currently visited page in the traversal.
+     * @param int|null $active_page_id The id of the page that the user has navigated to.
+     * @return TOCItem A TOCItem for the given $page and all of its descendants
+     */
+    private static function getTOCRecursive(WikiPage $page, int|null $active_page_id): TOCItem
+    {
+        $toc = new TOCItem($page->isNew() ? _('Wiki-Startseite') : $page->name);
+        $toc->setURL($page->isNew() ? URLHelper::getURL('dispatch.php/course/wiki/page') : URLHelper::getURL('dispatch.php/course/wiki/page/' . $page->id));
+        $toc->setActive($page->page_id === $active_page_id);
+        foreach ($page->children as $child) {
+            $childToc = self::getTOCRecursive($child, $active_page_id);
+            $toc->children[] = $childToc;
+        }
+        return $toc;
     }
 
 }
diff --git a/resources/assets/javascripts/lib/actionmenu.js b/resources/assets/javascripts/lib/actionmenu.js
index a81f78abddbdf7cc4b9de13183221a12a6bd6ce4..da85e332b8e40e4f3919d088484c681401b07d53 100644
--- a/resources/assets/javascripts/lib/actionmenu.js
+++ b/resources/assets/javascripts/lib/actionmenu.js
@@ -103,7 +103,11 @@ class ActionMenu
 
         // Reposition the menu?
         if (position) {
-            let parents = getScrollableParents(this.element, menu_width, menu_height);
+            let parents = getScrollableParents(this.element, menu_width, menu_height)
+                // Prevent us from appending the actionmenu outside of the <body>.
+                // (If it's appended outside of <body>, some CSS rules will not
+                // be applied, and the Z-ordering will be incorrect.)
+                .filter(parent => parent !== document.documentElement);
             if (parents.length > 0) {
                 const form = this.element.closest('form');
                 if (form) {
diff --git a/resources/assets/stylesheets/scss/contentbar.scss b/resources/assets/stylesheets/scss/contentbar.scss
index 3b0aff353705eb811e52c17015132f9436c69ad2..338737bac03b05c0c41c1236057b48aa40505100 100644
--- a/resources/assets/stylesheets/scss/contentbar.scss
+++ b/resources/assets/stylesheets/scss/contentbar.scss
@@ -2,7 +2,6 @@
     background-color: var(--dark-gray-color-10);
     display: flex;
     flex-wrap: nowrap;
-    height: auto;
     align-items: center;
     justify-content: flex-start;
     margin-bottom: 15px;
@@ -85,6 +84,9 @@
             margin: 0 7px;
 
             @-moz-document url-prefix() {
+                &.contentbar-toc-wrapper {
+                    margin-top: -4px;
+                }
                 &.contentbar-action-menu-wrapper {
                     margin-top: 2px;
                 }
diff --git a/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss b/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss
index 457cd4decd772324de985deb74e0b9ab9be79a27..28ecfbe2c716d4d53911c9f65d5aea62c87310c0 100644
--- a/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss
+++ b/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss
@@ -14,17 +14,13 @@ $consum_ribbon_width: calc(100% - 58px);
 }
 
 .cw-ribbon-wrapper-consume {
-    position: fixed;
-    padding-top: 15px;
+    padding-top: 7px;
     padding-bottom: 15px;
     background-color: var(--white);
-    width: $consum_ribbon_width;
-    height: 46px;
     z-index: 42;
 }
 
 .cw-ribbon-consume-bottom {
-    position: fixed;
     top: 75px;
     height: 15px;
     left: 0;
@@ -56,38 +52,46 @@ $consum_ribbon_width: calc(100% - 58px);
 }
 
 .cw-ribbon {
+    position: relative;
     display: flex;
     flex-wrap: wrap;
-    height: auto;
-    min-height: 30px;
+    align-items: center;
+    height: 60px;
+    min-height: 60px;
     margin-bottom: 15px;
-    padding: 1em;
+    padding: 0 2em;
     justify-content: space-between;
     background-color: var(--dark-gray-color-10);
 
     &.cw-ribbon-sticky {
         position: fixed;
         top: 50px;
-        width: calc(100% - 346px);
+        width: calc(100% - 375px);
         z-index: 40;
     }
 
     &.cw-ribbon-consume {
         width: $consum_ribbon_width;
-        position: fixed;
         margin-bottom: 0;
     }
 
     .cw-ribbon-wrapper-left {
         display: flex;
-        max-width: calc(100% - 106px);
+        gap: 1em;
+        min-width: 0;
+        width: 100%;
+        flex: 1;
 
         .cw-ribbon-nav {
             display: flex;
-            min-width: 75px;
+            align-items: center;
+
+            .contentbar-icon {
+                margin: 0 15px 0 10px;
 
-            &.single-icon {
-                min-width: 45px;
+                img {
+                    vertical-align: middle;
+                }
             }
         }
 
@@ -138,11 +142,18 @@ $consum_ribbon_width: calc(100% - 58px);
                     }
                 }
             }
+
+            .cw-ribbon-info-text {
+                font-size: small;
+                line-height: 1em;
+            }
         }
     }
 
     .cw-ribbon-wrapper-right {
         display: flex;
+        gap: 0.5em;
+        align-items: center;
 
         button {
             border: none;
@@ -174,7 +185,7 @@ $consum_ribbon_width: calc(100% - 58px);
 
         &.cw-ribbon-button-next {
             @include background-icon(arr_1right, clickable, 24);
-            margin: 0 1em 0 0;
+            margin: 0 0.5em 0 0;
         }
 
         &.cw-ribbon-button-prev-disabled {
@@ -185,15 +196,13 @@ $consum_ribbon_width: calc(100% - 58px);
 
         &.cw-ribbon-button-next-disabled {
             @include background-icon(arr_1right, inactive, 24);
-            margin: 0 1em 0 0;
+            margin: 0 0.5em 0 0;
             cursor: default;
         }
     }
 }
 
 .cw-ribbon-action-menu {
-    vertical-align: text-top;
-    margin: 2px 0 0 2px;
     &.is-open {
         z-index: 32;
     }
@@ -204,26 +213,25 @@ $consum_ribbon_width: calc(100% - 58px);
     border: solid thin var(--content-color-40);
     box-shadow: 2px 2px var(--dark-gray-color-30);
     position: absolute;
-    right: -570px;
-    top: 15px;
+    right: 0;
+    top: 0;
     height: 100%;
     max-width: calc(100% - 28px);
     display: flex;
     flex-flow: row;
-    transition: right 0.8s;
     z-index: 42;
 
-    &.unfold {
-        right: 0px;
-        margin-right: 15px;
-    }
-
     &.cw-ribbon-tools-consume {
-        position: fixed;
+        right: 15px;
+    }
 
-        &.unfold {
-            right: 15px;
-        }
+    &.cw-ribbon-slide-enter-active,
+    &.cw-ribbon-slide-leave-active {
+        transition: transform var(--transition-duration-superslow);
+    }
+    &.cw-ribbon-slide-enter,
+    &.cw-ribbon-slide-leave-to {
+        transform: translateX(calc(100% + 30px));
     }
 
     &.cw-ribbon-tools-sticky {
@@ -274,6 +282,7 @@ $consum_ribbon_width: calc(100% - 58px);
 
                 > .cw-tabs-nav {
                     border: none;
+                    height: 57px;
                     width: calc(100% - 48px);
 
                     > button {
@@ -281,7 +290,7 @@ $consum_ribbon_width: calc(100% - 58px);
                         max-width: unset;
                         flex-grow: 0.5;
                         &::after {
-                            margin-top: 16px;
+                            margin-top: 20px;
                         }
                     }
                 }
@@ -348,7 +357,7 @@ $consum_ribbon_width: calc(100% - 58px);
 
 .cw-structural-element-consumemode {
     .cw-ribbon-tools {
-        top: 25px;
+        top: 18px;
     }
 }
 
@@ -370,3 +379,48 @@ $consum_ribbon_width: calc(100% - 58px);
         height: 75px;
     }
 }
+
+// Rules extracted from PHP contentbar for use in ContentBar.vue
+.contentbar-button-wrapper {
+    height: 24px;
+    margin: 0 7px;
+
+    @-moz-document url-prefix() {
+        &.contentbar-toc-wrapper {
+            margin-top: -4px;
+        }
+        &.contentbar-action-menu-wrapper {
+            margin-top: 2px;
+        }
+    }
+
+    .contentbar-button,
+    .cw-ribbon-button {
+        background-color: transparent;
+        background-position: center;
+        background-repeat: no-repeat;
+        background-size: 24px;
+        border: none;
+        cursor: pointer;
+        display: inline-block;
+        height: 24px;
+        width: 24px;
+
+        &.contentbar-button-menu,
+        &.cw-ribbon-button-menu {
+            @include background-icon(table-of-contents, clickable, 24);
+        }
+
+        &.contentbar-button-zoom::before {
+            left: -5px;
+            position: relative;
+            top: -2px;
+        }
+
+        @-moz-document url-prefix() {
+            &.contentbar-button-zoom::before {
+                top: -3px;
+            }
+        }
+    }
+}
diff --git a/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss b/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss
index cf28364678c8806436cc29d6ad111a2f803c1368..023b31d162b2e3a435f06c7f81d1a5c05997c82e 100644
--- a/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss
+++ b/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss
@@ -24,7 +24,7 @@
         &::after {
             display: block;
             margin-top: 4px;
-            margin-bottom: -5px;
+            margin-bottom: -12px;
             margin-left: -14px;
             width: calc(100% + 28px);
             content: '';
diff --git a/resources/assets/stylesheets/scss/responsive.scss b/resources/assets/stylesheets/scss/responsive.scss
index c2ca5c367fd34555d9aa8be3946e4e88041feca5..0a84d3ea95e36ea4ee3000e14842daf919c7086b 100644
--- a/resources/assets/stylesheets/scss/responsive.scss
+++ b/resources/assets/stylesheets/scss/responsive.scss
@@ -329,7 +329,7 @@ $sidebarOut: -330px;
         &.responsive-show {
             animation: slide-in var(--transition-duration) forwards;
             position: sticky;
-            top: 100px;
+            top: 110px;
             visibility: visible;
         }
 
@@ -383,10 +383,6 @@ $sidebarOut: -330px;
     }
 
     #responsive-contentbar {
-        justify-content: stretch;
-        margin-bottom: 15px;
-        padding-bottom: 0.5em;
-
         .contentbar-nav,
         .cw-ribbon-nav {
             .contentbar-button {
@@ -407,71 +403,20 @@ $sidebarOut: -330px;
                 }
             }
 
-        }
-
-        .contentbar-wrapper-left {
-            flex: 1;
-            max-width: calc(100% - 70px);
-            min-width: 0;
-            width:100%;
-
-            & > .contentbar-icon {
-                margin-right: 15px;
-            }
-
-            .contentbar-breadcrumb {
-                font-size: $font-size-large;
-
-                > img {
-                    margin-left: 15px;
-                    width: 24px;
-                }
-
-                > span {
-                    display: inline;
-                    flex-shrink: 10000;
-                    min-width: 0;
-                    overflow: hidden;
-                    text-overflow: ellipsis;
-                    white-space: nowrap;
-                }
-            }
-        }
-
-        > .contentbar-wrapper-right {
-            flex: 0;
-            left: 5px;
-            position: relative;
-
-            .contentbar-button,
-            nav {
-                position: relative;
+            img {
+                vertical-align: middle;
             }
         }
 
-        &.cw-ribbon {
-            .cw-ribbon-tools {
-                max-width: calc(100% - 2px);
-                top: 0;
-                margin-right: 0;
-            }
+        .cw-ribbon-tools {
+            right: 16px;
         }
 
-        &.cw-ribbon-sticky {
-            position: unset;
-            width: calc(100vw - 30px);
-        }
     }
 
     #toc {
-        max-width: 100vw;
-        position: absolute;
-        right: -8px;
-        top: -21px;
-    }
-
-    #toc_header {
-        height: 47px;
+        margin-right: -2px;
+        margin-top: -7px;
     }
 
     #main-footer {
@@ -509,7 +454,30 @@ $sidebarOut: -330px;
     #responsive-contentbar {
         &.cw-ribbon-sticky {
             position: unset;
-            width: calc(100vw - 30px);
+            width: unset;
+        }
+
+        .cw-ribbon-breadcrumb {
+            max-width: 100%;
+            min-width: unset;
+        }
+
+        .cw-ribbon-info-text,
+        .contentbar-icon {
+            display: none;
+        }
+
+        &:not(.cw-ribbon) {
+            .contentbar-wrapper-left {
+                flex: 1;
+                max-width: 100%;
+                text-overflow: ellipsis;
+            }
+        }
+
+        .cw-ribbon-tools {
+            max-width: 100vw;
+            right: 0;
         }
     }
 
@@ -521,10 +489,9 @@ $sidebarOut: -330px;
         height: calc(100% - 100px);
         overflow-y: auto;
         position: fixed;
-        top: 75px;
         transform: translateX($sidebarOut);
         -webkit-transform: translateX($sidebarOut);
-        top: 80px;
+        top: 110px;
         z-index: 100;
 
         &.responsive-show {
@@ -536,6 +503,12 @@ $sidebarOut: -330px;
         }
     }
 
+    #toc {
+        margin-right: -16px;
+        max-width: 100vw;
+        width: 100vw;
+    }
+
     #system-notifications {
         top: 0;
         position: fixed;
@@ -619,13 +592,13 @@ $sidebarOut: -330px;
 
             .contentbar-nav,
             .cw-ribbon-nav {
-                margin-left: -8px;
+                margin-left: -6px;
             }
         }
 
         #content-wrapper {
             flex: 1;
-            margin-top: 75px;
+            margin-top: 55px;
             min-height: calc(100vh - 150px);
         }
     }
@@ -686,11 +659,8 @@ $sidebarOut: -330px;
     }
 
     #toc {
-        position: absolute;
-        right: -29px;
-        top: -25px;
+        margin-right: 12px;
     }
-
 }
 
 html:not(.responsive-display):not(.fullscreen-mode) {
diff --git a/resources/assets/stylesheets/scss/table_of_contents.scss b/resources/assets/stylesheets/scss/table_of_contents.scss
index b361a9033fd96a23d06c5dcd95dba1d6720ecbd8..e323c794c612f8ca8d934181e697549cf4b9276a 100644
--- a/resources/assets/stylesheets/scss/table_of_contents.scss
+++ b/resources/assets/stylesheets/scss/table_of_contents.scss
@@ -15,45 +15,57 @@ ul.numberedchapters {
     display: none;
 }
 
-#cb-toc:checked + .check-box + #cb-toc-close + article.toc_overview, button#toc-button:hover article.toc_overview {
-    visibility: visible;
-    width: 540px;
-    overflow: hidden;
-}
-
-#cb-toc-close:checked article.toc_overview {
-    visibility: hidden;
-    width: 0;
-}
-
-.toc_overview {
-    visibility: hidden;
-    width: 0%;
+#toc {
+    margin: 11px;
+    text-align: left;
     z-index: 100;
     position: absolute;
-    right: -22px;
-    top: -25px;
+    right: -10px;
+    top: -11px;
     background-color: var(--white);
     border: 1px solid var(--content-color-40);
     margin-bottom: 10px;
     box-shadow: 2px 2px var(--dark-gray-color-30);
-
+    width: min(100%, 540px);
 
     > section {
         max-width: 100%;
         overflow-y: scroll;
         height: 580px;
         margin-top: 7px;
+        padding: 5px 15px;
     }
-}
 
-#toc {
-    margin: 10px;
-    text-align: left;
+    .toc-hide-button {
+        position: absolute;
+        border: none;
+        height: 36px;
+        width: 24px;
+        min-width: 24px;
+        margin-right: 1em;
+        padding: 0 4px;
+        right: 0;
+        top: 12px;
+        cursor: pointer;
+        @include background-icon(decline, clickable, 24);
+        background-repeat: no-repeat;
+        background-size: 24px;
+        background-position: center right;
+        background-color: var(--white);
+    }
+
+    &.cw-ribbon-slide-enter-active,
+    &.cw-ribbon-slide-leave-active {
+        transition: transform var(--transition-duration-superslow);
+    }
+    &.cw-ribbon-slide-enter,
+    &.cw-ribbon-slide-leave-to {
+        transform: translateX(calc(100% + 30px));
+    }
 }
 
 #toc_header {
-    height: 58px;
+    height: 57px;
     overflow: hidden;
     background-color: var(--white);
     color: var(--black);
@@ -73,14 +85,10 @@ ul.numberedchapters {
 #toc_h1 {
     color: var(--black);
     font-weight: 500;
-    margin-left: 10px;
+    margin-left: 25px;
     margin-bottom: unset;
 }
 
-.toc_transform {
-    transition: all var(--transition-duration) ease;
-}
-
 #main_content {
     opacity: 1;
 
@@ -121,6 +129,13 @@ section > .toc {
         img, svg {
             vertical-align: bottom;
         }
+
+        a,
+        span {
+            img {
+                margin-right: 10px;
+            }
+        }
     }
 
     li#chap1 {
@@ -196,10 +211,6 @@ section > .toc {
         width:375px;
     }
 
-    #toc {
-        max-width: 94%;
-    }
-
     ul.breadcrumb {
         list-style: none;
         font-size: 18px;
diff --git a/resources/studip.d.ts b/resources/studip.d.ts
index 5ec8fef976240e59684d18ee3de1eb3cf657cc4d..ec5c065698cb6b778a29c76eafd97d82524f3fa3 100644
--- a/resources/studip.d.ts
+++ b/resources/studip.d.ts
@@ -22,3 +22,24 @@ export interface InstalledLanguage {
     picture: string;
     selected: boolean;
 }
+
+enum iconTypes {
+    'accept',
+    'attention',
+    'clickable',
+    'inactive',
+    'info',
+    'info_alt',
+    'navigation',
+    'new',
+    'sort',
+    'status-green',
+    'status-red',
+    'status-yellow'
+}
+
+export interface Icon {
+    role: iconTypes;
+    shape: string;
+    attributes: Record<string, unknown>;
+}
diff --git a/resources/vue/components/ContentBar.vue b/resources/vue/components/ContentBar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..539c5825f8f8af9eaddcbd75d04b48e3ce1aacb8
--- /dev/null
+++ b/resources/vue/components/ContentBar.vue
@@ -0,0 +1,163 @@
+<template>
+    <div :class="{ 'cw-ribbon-wrapper-consume': consumeMode }"
+         :id="isContentBar ? 'contentbar' : undefined">
+        <div v-show="stickyRibbon"
+             class="cw-ribbon-sticky-top"></div>
+        <div class="cw-ribbon-header-container"
+             ref="headerContainer">
+            <header
+                ref="header"
+                :id="isContentBar ? 'cw-ribbon' : undefined"
+                class="cw-ribbon"
+                :class="{ 'cw-ribbon-sticky': stickyRibbon, 'cw-ribbon-consume': consumeMode }"
+            >
+                <div class="cw-ribbon-wrapper-left">
+                    <nav class="cw-ribbon-nav contentbar-nav"
+                         :class="buttonsClass">
+                        <div class="contentbar-icon"
+                             v-if="icon">
+                            <a href="">
+                                <studip-icon :shape="icon"
+                                             role="navigation"
+                                             :size="32" />
+                            </a>
+                        </div>
+                        <slot name="buttons-left" />
+                    </nav>
+                    <nav class="cw-ribbon-breadcrumb">
+                        <span v-if="title">
+                            <a href="">
+                                {{ title ?? $gettext('(Kein Titel)') }}
+                            </a>
+                        </span>
+                        <slot v-if="breadcrumbFallback && $slots['breadcrumb-fallback']" name="breadcrumb-fallback" />
+                        <slot v-else name="breadcrumb-list" />
+                        <div class="cw-ribbon-info-text">
+                            <slot name="info-text" />
+                        </div>
+                    </nav>
+                </div>
+                <div class="cw-ribbon-wrapper-right">
+                    <slot name="buttons-right" />
+                    <ContentBarTableOfContents v-if="toc" :toc="toc" />
+                    <slot name="menu" />
+                </div>
+                <slot name="other" />
+            </header>
+        </div>
+        <div v-if="stickyRibbon" class="cw-ribbon-sticky-bottom"></div>
+        <div v-if="stickyRibbon" class="cw-ribbon-sticky-spacer"></div>
+    </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+
+import '../../assets/stylesheets/scss/courseware/layouts/ribbon.scss';
+import StudipIcon from './StudipIcon.vue';
+import { store } from '../../assets/javascripts/chunks/vue';
+import { TOCItem, traverse } from './table-of-contents';
+import ContentBarTableOfContents from './ContentBarTableOfContents.vue';
+
+export default defineComponent({
+    name: 'ContentBar',
+    components: { ContentBarTableOfContents, StudipIcon },
+    data() {
+        return {
+            stickyRibbon: false,
+            // This flag lets us run a hook the first time that this component's
+            // dom is updated by vue.  See updated() hook.
+            hasPerformedFirstUpdate: false,
+            // This intersection observer allows us to respond to changes in
+            // the contentbar's visibility so that it is always displayed
+            // at the correct height on the page after being hidden or shown for
+            // any reason (e.g. when Courseware search results are opened/closed).
+            observer: undefined as IntersectionObserver | undefined,
+        };
+    },
+    props: {
+        // (Optional) A class that is applied to the <nav> element where the
+        // 'icon', if any (see 'icon' prop), and the buttons-left slot are displayed.
+        buttonsClass: String,
+        // (Optional) If provided, displayed in the same place as the breadcrumb-list
+        // and breadcrumb-fallback slots.
+        title: String,
+        // (Optional) If provided, displays the given icon before the 'icons-left' slot.
+        icon: String,
+        // If true, this element serves as the global contentbar.
+        // It will stick to the top of the screen when the page is scrolled down,
+        // and it will be pinned to the top of the screen in compact mode.
+        // If false, this element will not be used as the global contentbar.
+        // It will just be a normal element on the page that looks the same as the
+        // global contentbar, but does not have any special sticky behavior.
+        isContentBar: {
+            type: Boolean,
+            required: false,
+        },
+        // (Optional) If provided, a 'table of contents' icon will be shown on
+        // the right side of the ContentBar. When clicked, it will open/close a
+        // panel with the table of contents inside.
+        toc: Object as PropType<TOCItem>,
+    },
+    mounted() {
+        window.addEventListener('scroll', this.handleScroll);
+        this.observer = new IntersectionObserver(this.intersectionCallback);
+        this.observer.observe(this.$el);
+        this.$forceUpdate();
+    },
+    updated() {
+        // The "Responsive Toolbar" works by reaching inside the DOM template of
+        // this component and grabbing some elements from it to stick them
+        // in the ResponsiveToolbar, jquery-style.
+        // That only works if the elements are actually present in the DOM
+        // when the courseware-contentbar-mounted event is fired.
+        // To ensure that that is the case, we defer emitting that event until
+        // this component's dom has been fully rendered for the first time.
+        // This trick is brought to you by the Vue 2 docs:
+        // https://v2.vuejs.org/v2/api/#updated
+        if (this.hasPerformedFirstUpdate) {
+            return;
+        }
+        this.hasPerformedFirstUpdate = true;
+        this.$nextTick(() => {
+            if (this.isContentBar) {
+                // TODO rename this event.
+                window.STUDIP.eventBus.emit('courseware-contentbar-mounted', this);
+            }
+        });
+    },
+    beforeDestroy() {
+        if (this.isContentBar) {
+            window.STUDIP.eventBus.emit('courseware-contentbar-before-destroy', this);
+        }
+        window.removeEventListener('scroll', this.handleScroll);
+        this.observer!.disconnect();
+    },
+    watch: {
+        stickyRibbon(value) {
+            this.$emit('stickyRibbonChange', value);
+        },
+    },
+    computed: {
+        consumeMode(): boolean {
+            // We have to access the global studipStore over an import rather than
+            // using $store/mapState/mapGetters/etc.,  because this component is
+            // compatible with Courseware, and in the various Courseware apps,
+            // $store does not include the global StudipStore.
+            return store.state.studip.consumeMode;
+        },
+        breadcrumbFallback(): boolean {
+            return window.outerWidth < 1200;
+        },
+    },
+    methods: {
+        intersectionCallback(entries: IntersectionObserverEntry[]) {
+            this.handleScroll();
+        },
+        handleScroll() {
+            const top = this.$el.getBoundingClientRect().top;
+            this.stickyRibbon = this.isContentBar && top <= 50 && !this.consumeMode;
+        },
+    },
+});
+</script>
diff --git a/resources/vue/components/ContentBarBreadcrumbs.vue b/resources/vue/components/ContentBarBreadcrumbs.vue
new file mode 100644
index 0000000000000000000000000000000000000000..35606b46178940d9d9e5f4e8adc4e59ae16debd5
--- /dev/null
+++ b/resources/vue/components/ContentBarBreadcrumbs.vue
@@ -0,0 +1,78 @@
+<template>
+    <ul>
+        <li v-for="(breadcrumb, index) in breadcrumbs" class="cw-ribbon-breadcrumb-item" :key="index">
+            <span v-if="breadcrumb.active">{{ breadcrumb.title }}</span>
+            <a v-else :href="breadcrumb.url">{{ breadcrumb.title }}</a>
+        </li>
+    </ul>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import { TOCItem, traverse } from './table-of-contents';
+
+interface Breadcrumb {
+    title: string;
+    url: string;
+    active: boolean;
+}
+
+export default defineComponent({
+    name: 'ContentBarBreadcrumbs',
+    props: {
+        // The table of contents tree for the page that is currently open.
+        toc: {
+            type: Object as PropType<TOCItem>,
+            required: true,
+        },
+    },
+    computed: {
+        // Convert the nested TOCItem into a list of breadcrumbs we can iterate through
+        // in the template.
+        breadcrumbs(): Breadcrumb[] {
+            // First, clone the toc and add parent references to it.
+            // (The parent references are lost in serialization from PHP to JS.)
+            const tocClone = JSON.parse(JSON.stringify(this.toc));
+            this.addParentReferences(tocClone);
+
+            // Then, find the TOCItem corresponding to the page that is currently open.
+            const activeTocItem = this.findActiveTocItem(tocClone);
+            if (!activeTocItem) {
+                console.error('No TOCItem is marked as active. No breadcrumbs will be rendered.');
+                return [];
+            }
+
+            // Finally, iterate upwards from the active TOC Item, through its parent, grandparent, ...
+            // up to the root, generating a breadcrumb at each step of the way.
+            const breadcrumbs = [{ title: activeTocItem.title, url: activeTocItem.url, active: true }];
+            let current = activeTocItem;
+            while (current.parent) {
+                current = current.parent;
+                breadcrumbs.push({ title: current.title, url: current.url, active: false });
+            }
+            return breadcrumbs.reverse();
+        },
+    },
+    methods: {
+        // Find the TOCItem, if any, that is marked as active in the given toc tree.
+        findActiveTocItem(toc: TOCItem): TOCItem | undefined {
+            let activeItem: TOCItem | undefined;
+            traverse(toc, (item) => {
+                if (item.active) {
+                    activeItem = item;
+                }
+            });
+            return activeItem;
+        },
+        // Augment each node in the given toc tree with a reference to its parent.
+        addParentReferences(tocItem: TOCItem, parent?: TOCItem): void {
+            if (parent) {
+                tocItem.parent = parent;
+            }
+            for (let child of tocItem.children) {
+                this.addParentReferences(child, tocItem);
+            }
+        },
+    },
+});
+</script>
diff --git a/resources/vue/components/ContentBarTableOfContents.vue b/resources/vue/components/ContentBarTableOfContents.vue
new file mode 100644
index 0000000000000000000000000000000000000000..525c4d69872b298a5791dc3f3d3980efd7da75c9
--- /dev/null
+++ b/resources/vue/components/ContentBarTableOfContents.vue
@@ -0,0 +1,63 @@
+<template>
+    <div class="contentbar-button-wrapper contentbar-toc-wrapper">
+        <button v-if="!tocOpen"
+                class="cw-ribbon-button cw-ribbon-button-menu"
+                :title="$gettext('Inhaltsverzeichnis öffnen')"
+                @click.prevent="showTOC(true)"></button>
+        <transition name="cw-ribbon-slide" appear>
+            <article v-if="tocOpen" id="toc">
+                <header id="toc_header">
+                    <h1 id="toc_h1">
+                        {{ $gettextInterpolate('Inhalt (%{count} Elemente)', { count: tocItemsCount }) }}
+                    </h1>
+                    <button class="toc-hide-button"
+                            :title="$gettext('Inhaltsverzeichnis schließen')"
+                            @click.prevent="showTOC(false)"></button>
+                </header>
+                <section>
+                    <ul class="toc">
+                        <ContentBarTocItemList :toc="toc" />
+                    </ul>
+                </section>
+            </article>
+        </transition>
+    </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import { TOCItem, traverse } from './table-of-contents';
+import ContentBarTocItemList from './ContentBarTocItemList.vue';
+
+export default defineComponent({
+    name: 'ContentBarTableOfContents',
+    components: { ContentBarTocItemList },
+    props: {
+        toc: {
+            required: true,
+            type: Object as PropType<TOCItem>,
+        },
+    },
+    data() {
+        return {
+            tocOpen: false
+        }
+    },
+    computed: {
+        tocItemsCount(): number {
+            if (!this.toc) {
+                return 0;
+            }
+            // Count how many items are in the TOC tree.
+            let count = 0;
+            traverse(this.toc, (item) => count++);
+            return count;
+        },
+    },
+    methods: {
+        showTOC(state = true) {
+            this.tocOpen = state;
+        }
+    }
+});
+</script>
diff --git a/resources/vue/components/ContentBarTocItemList.vue b/resources/vue/components/ContentBarTocItemList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..15101c480ca224c98de0443498514ebb0dd42d45
--- /dev/null
+++ b/resources/vue/components/ContentBarTocItemList.vue
@@ -0,0 +1,33 @@
+<template>
+    <li class="chapter" :class="{ active: toc.active }">
+        <span v-if="toc.active">
+            <StudipIcon v-if="toc.icon" :shape="toc.icon.shape" role="info" :size="24" />
+            {{ toc.title }}
+        </span>
+        <a v-else class="navigate" :href="toc.url">
+            <StudipIcon v-if="toc.icon" :shape="toc.icon.shape" role="info" :size="24" />
+            {{ toc.title }}
+        </a>
+        <ul v-if="toc.children.length > 0">
+            <ContentBarTocItemList v-for="child in toc.children" :key="child.url" :toc="child" />
+        </ul>
+    </li>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+
+import { TOCItem } from './table-of-contents';
+import StudipIcon from './StudipIcon.vue';
+
+export default defineComponent({
+    name: 'ContentBarTocItemList',
+    components: { StudipIcon },
+    props: {
+        toc: {
+            required: true,
+            type: Object as PropType<TOCItem>,
+        },
+    },
+});
+</script>
diff --git a/resources/vue/components/WikiEditor.vue b/resources/vue/components/WikiEditor.vue
index 069b2ff750d1213547a660bd3f63faeda172c7ee..3b15bb03a7a925f19825eca135c2346bc3254339 100644
--- a/resources/vue/components/WikiEditor.vue
+++ b/resources/vue/components/WikiEditor.vue
@@ -1,5 +1,14 @@
 <template>
     <div>
+        <ContentBar isContentBar icon="wiki" :toc="toc">
+            <template #info-text>
+                {{ $gettext('Zuletzt gespeichert') }}:
+                <studip-date-time :timestamp="Math.floor(lastSaveDate / 1000)"
+                                  :relative="true"
+                />
+            </template>
+            <template #breadcrumb-list><content-bar-breadcrumbs :toc="toc"/></template>
+        </ContentBar>
         <form :action="saveUrl" method="post" class="default" v-show="isEditing">
             <input type="hidden" :name="csrf.name" :value="csrf.value">
 
@@ -67,21 +76,18 @@
 
         <wiki-editor-online-users :users="onlineUsers"></wiki-editor-online-users>
 
-        <mounting-portal :mount-to="`.wiki-last-edited-${pageId}`">
-            <studip-date-time :timestamp="Math.floor(lastSaveDate / 1000)"
-                              :relative="true"
-            ></studip-date-time>
-        </mounting-portal>
     </div>
 </template>
 <script>
 import WikiEditorOnlineUsers from "./WikiEditorOnlineUsers.vue";
 import StudipDateTime from "./StudipDateTime.vue";
 import JSUpdater from "@/assets/javascripts/lib/jsupdater";
+import ContentBar from "./ContentBar.vue";
+import ContentBarBreadcrumbs from "./ContentBarBreadcrumbs.vue";
 
 export default {
     name: 'wiki-editor',
-    components: {StudipDateTime, WikiEditorOnlineUsers },
+    components: { ContentBarBreadcrumbs, ContentBar, StudipDateTime, WikiEditorOnlineUsers },
     props: {
         cancelUrl: {
             type: String,
@@ -114,6 +120,10 @@ export default {
         users: {
             type: Array,
             default: () => []
+        },
+        toc: {
+            type: Object,
+            required: true
         }
     },
     data() {
diff --git a/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue b/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue
index f7cfff04c1066d8b4ae077227289fc10b921efa3..3f6eaba4489d9f5736bd6ecea2136f980230e4b0 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue
@@ -1,168 +1,99 @@
 <template>
-    <div :class="{ 'cw-ribbon-wrapper-consume': consumeMode }" :id="isContentBar ? 'contentbar' : null" >
-        <div v-show="stickyRibbon" class="cw-ribbon-sticky-top"></div>
-        <header :id="isContentBar ? 'cw-ribbon' : null" class="cw-ribbon" :class="{ 'cw-ribbon-sticky': stickyRibbon, 'cw-ribbon-consume': consumeMode }">
-            <div class="cw-ribbon-wrapper-left">
-                <nav class="cw-ribbon-nav" :class="buttonsClass">
-                    <slot name="buttons" />
-                </nav>
-                <nav class="cw-ribbon-breadcrumb">
-                    <ul>
-                        <slot v-if="breadcrumbFallback" name="breadcrumbFallback" />
-                        <slot v-else name="breadcrumbList" />
-                    </ul>
-                </nav>
-            </div>
-            <div class="cw-ribbon-wrapper-right">
-                <button
-                    v-if="showToolbarButton"
-                    class="cw-ribbon-button cw-ribbon-button-menu"
-                    :title="textRibbon.toolbar"
-                    @click.prevent="activeToolbar"
-                >
-                </button>
-                <slot name="menu" />
-            </div>
-            <div v-if="consumeMode" class="cw-ribbon-consume-bottom"></div>
-            <courseware-ribbon-toolbar
-                v-if="showTools"
-                :toolsActive="unfold"
-                :stickyRibbon="stickyRibbon"
-                :class="{ 'cw-ribbon-tools-sticky': stickyRibbon }"
-                :style="{ height: toolbarHeight + 'px' }"
-                :canEdit="canEdit"
-                @deactivate="deactivateToolbar"
-                @blockAdded="$emit('blockAdded')"
-            />
-        </header>
-        <div v-if="stickyRibbon" class="cw-ribbon-sticky-bottom"></div>
-        <div v-if="stickyRibbon" class="cw-ribbon-sticky-spacer"></div>
-    </div>
+    <content-bar is-content-bar @stickyRibbonChange="onStickyRibbonChange">
+        <template #buttons-right>
+            <button
+                class="cw-ribbon-button cw-ribbon-button-menu"
+                :title="strings.toolbar"
+                @click.prevent="activateToolbar"
+            ></button>
+        </template>
+        <template #other>
+            <transition name="cw-ribbon-slide">
+                <courseware-ribbon-toolbar
+                    ref="toolbar"
+                    v-show="showToolbar"
+                    :stickyRibbon="stickyRibbon"
+                    :class="{ 'cw-ribbon-tools-sticky': stickyRibbon }"
+                    :style="{ height: toolbarHeight + 'px' }"
+                    @deactivate="deactivateToolbar"
+                    @blockAdded="$emit('blockAdded')"
+                />
+            </transition>
+        </template>
+        <!--  Pass these slots through to the ContentBar. -->
+        <template #menu><slot name="menu" /></template>
+        <template #buttons-left><slot name="buttons-left" /></template>
+        <template #breadcrumb-list><slot name="breadcrumb-list" /></template>
+        <template #breadcrumb-fallback><slot name="breadcrumb-fallback" /></template>
+        <template #info-text><slot name="info-text"/></template>
+    </content-bar>
 </template>
 
-<script>
+<script lang="ts">
+import ContentBar from '../../ContentBar.vue';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import Vue from 'vue';
 import CoursewareRibbonToolbar from './CoursewareRibbonToolbar.vue';
-import { mapActions, mapGetters } from 'vuex';
+import { store } from '../../../../assets/javascripts/chunks/vue';
 
-export default {
-    name: 'courseware-ribbon',
-    components: {
-        CoursewareRibbonToolbar,
-    },
-    props: {
-        canEdit: Boolean,
-        showToolbarButton: {
-            default: true,
-            type: Boolean
-        },
-        showModeSwitchButton: {
-            default: true,
-            type: Boolean
-        },
-        buttonsClass: String,
-        isContentBar: {
-            type: Boolean,
-            default: false
-        }
-    },
+export default Vue.extend({
+    name: 'CoursewareRibbon',
+    components: { CoursewareRibbonToolbar, ContentBar },
     data() {
         return {
-            readModeActive: false,
+            // This value is derived from stickyRibbonChange events emitted by
+            // the ContentBar component (see template).
             stickyRibbon: false,
-            textRibbon: {
-                toolbar: this.$gettext('Inhaltsverzeichnis'),
-                fullscreen_on: this.$gettext('Fokusmodus einschalten'),
-                fullscreen_off: this.$gettext('Fokusmodus ausschalten'),
-            },
-            unfold: false,
-            showTools: false,
         };
     },
     computed: {
         ...mapGetters({
-            consumeMode: 'consumeMode',
-            toolsActive: 'showToolbar'
+            showToolbar: 'showToolbar',
         }),
-        breadcrumbFallback() {
-            return window.outerWidth < 1200;
+        consumeMode(): boolean {
+            // TODO ensure that there is only one global StudipStore / 'studip' store module
+            //  across Courseware and chunks/vue.js.
+            // Currently, the 'studip' module of the courseware store is deceivingly named.
+            // It is a completely different store than the one in chunks/vue.js.
+            // It just happens to have a module with the same name, 'studip'.
+            // So, to access the global studipStore, we have to import it and access it like this.
+            return store.state.studip.consumeMode;
+        },
+        strings() {
+            return {
+                toolbar: this.$gettext('Inhaltsverzeichnis'),
+            };
         },
         toolbarHeight() {
             if (this.stickyRibbon) {
-                return parseInt(window.innerHeight * 0.75);
+                return window.innerHeight * 0.75;
             } else {
-                return parseInt(Math.min(window.innerHeight * 0.75, window.innerHeight - 197));
+                return Math.min(window.innerHeight * 0.75, window.innerHeight - 197);
             }
-        }
+        },
+    },
+    watch: {
+        consumeMode(newState: boolean) {
+            if (newState) {
+                console.log('consumeMode watcher ', newState, 'setting coursewareViewMode "read"');
+                this.coursewareViewMode('read');
+            }
+        },
     },
     methods: {
+        onStickyRibbonChange(value: boolean) {
+            this.stickyRibbon = value;
+        },
         ...mapActions({
-            coursewareConsumeMode: 'coursewareConsumeMode',
             coursewareViewMode: 'coursewareViewMode',
-            coursewareShowToolbar: 'coursewareShowToolbar'
-
+            coursewareShowToolbar: 'coursewareShowToolbar',
         }),
-        toggleConsumeMode() {
-            STUDIP.eventBus.emit('toggle-focus-mode', !this.consumeMode);
-            if (!this.consumeMode) {
-                document.body.classList.add('consuming_mode');
-                this.coursewareConsumeMode(true);
-                this.coursewareViewMode('read');
-            } else {
-                this.coursewareConsumeMode(false);
-                document.body.classList.remove('consuming_mode');
-            }
-        },
-        activeToolbar() {
+        activateToolbar() {
             this.coursewareShowToolbar(true);
         },
         deactivateToolbar() {
             this.coursewareShowToolbar(false);
         },
-        handleScroll() {
-            if (window.outerWidth > 767) {
-                this.stickyRibbon = window.scrollY > 128 && !this.consumeMode;
-            } else {
-                this.stickyRibbon = window.scrollY > 75 && !this.consumeMode;
-            }
-        },
-    },
-    mounted() {
-        window.addEventListener('scroll', this.handleScroll);
-        if (this.isContentBar) {
-            STUDIP.eventBus.emit('courseware-contentbar-mounted', this);
-        }
-
-        this.globalOn('switch-focus-mode', (state) => {
-            if (state !== this.consumeMode) {
-                this.toggleConsumeMode();
-            }
-        });
     },
-    beforeDestroy() {
-        STUDIP.eventBus.off('switch-focus-mode');
-    },
-    watch: {
-        toolsActive(newState, oldState) {
-            let view = this;
-            if(newState) {
-                this.showTools = true;
-                setTimeout(() => {view.unfold = true}, 10);
-            } else {
-                this.unfold = false;
-                setTimeout(() => {
-                    if(!view.toolsActive) {
-                        view.showTools = false;
-                    }
-                }, 800);
-            }
-        },
-        consumeMode(newState) {
-            if (newState) {
-                document.body.classList.add('consuming_mode');
-            } else {
-                document.body.classList.remove('consuming_mode');
-            }
-        }
-    }
-};
+});
 </script>
diff --git a/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue b/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue
index 2a92579fdc0f749e01acfac679e296069cce98a8..fe99808e6406006045751341a6e18f678c246626 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue
@@ -2,7 +2,7 @@
     <focus-trap v-model="trap" :initial-focus="() => initialFocusElement" :clickOutsideDeactivates="true" :fallbackFocus ="() => fallbackFocusElement">
         <div
             class="cw-ribbon-tools"
-            :class="{ unfold: toolsActive, 'cw-ribbon-tools-consume': consumeMode }"
+            :class="{ 'cw-ribbon-tools-consume': consumeMode }"
         >
             <div class="cw-ribbon-tool-content">
                 <div class="cw-ribbon-tool-content-nav">
@@ -49,6 +49,7 @@ import CoursewareToolsContents from './CoursewareToolsContents.vue';
 import CoursewareToolsUnits from './CoursewareToolsUnits.vue';
 import { FocusTrap } from 'focus-trap-vue';
 import { mapActions, mapGetters } from 'vuex';
+import { store } from "../../../../assets/javascripts/chunks/vue";
 
 export default {
     name: 'courseware-ribbon-toolbar',
@@ -60,16 +61,6 @@ export default {
         FocusTrap,
     },
     props: {
-        toolsActive: Boolean,
-        canEdit: Boolean,
-        disableSettings: {
-            type: Boolean,
-            default: false,
-        },
-        disableAdder: {
-            type: Boolean,
-            default: false,
-        },
         stickyRibbon: {
             type: Boolean,
             default: false,
@@ -84,9 +75,11 @@ export default {
         };
     },
     computed: {
+        consumeMode() {
+          return store.state.studip.consumeMode;
+        },
         ...mapGetters({
             userIsTeacher: 'userIsTeacher',
-            consumeMode: 'consumeMode',
             containerAdder: 'containerAdder',
             adderStorage: 'blockAdder',
             viewMode: 'viewMode',
@@ -108,28 +101,25 @@ export default {
             coursewareContainerAdder: 'coursewareContainerAdder',
         }),
         scrollToCurrent() {
-            setTimeout(() => {
-                let contents = this.$refs.contents.$el; 
-                let current = contents.querySelector('.cw-tree-item-link-current');
-                if (current) {
-                    contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' });
-                }
-            }, 360);
+            let contents = this.$refs.contents.$el;
+            let current = contents.querySelector('.cw-tree-item-link-current');
+            if (current) {
+                contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' });
+            }
         },
-    },
-    mounted () {
-        this.scrollToCurrent();
-    },
-    watch: {
-        toolsActive(newValue) {
+        activate() {
             const focusElement = this.$refs.tabs.getTabButtonByAlias(this.selectedToolbarItem);
-            if (newValue && focusElement) {
-                setTimeout(() => {
-                    this.initialFocusElement = focusElement;
-                    this.trap = true;
-                }, 300);
+            if (focusElement) {
+                this.initialFocusElement = focusElement;
+                this.trap = true;
             }
         },
     },
+    mounted() {
+        this.$nextTick(() => {
+            this.activate();
+            this.$nextTick(() => this.scrollToCurrent());
+        });
+    },
 };
 </script>
diff --git a/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue b/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue
index 6efbca260e2e82b396bdfe6ee6e1281da37431e0..5d7547d4d9e42bc0ae2532b4b01fefb199aa624e 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue
@@ -1,14 +1,10 @@
 <template>
     <div role="region" id="search" aria-live="polite">
-        <courseware-ribbon
-            :showToolbarButton="false"
-            :showModeSwitchButton="false"
-            buttonsClass="single-icon"
-        >
-            <template #buttons>
+        <ContentBar>
+            <template #buttons-left>
                 <studip-icon shape="search" :size="24" />
             </template>
-            <template #breadcrumbList>
+            <template #breadcrumb-list>
                 <translate>Suchergebnisse</translate>
             </template>
             <template #menu>
@@ -16,7 +12,7 @@
                     <studip-icon shape="decline" :size="24"/>
                 </button>
             </template>
-        </courseware-ribbon>
+        </ContentBar>
         <div id="search-results">
             <article v-if="searchResults.length > 0">
                 <section v-for="result in searchResults" :key="result['structural-element-id']">
@@ -49,15 +45,15 @@
 </template>
 
 <script>
-import CoursewareRibbon from './CoursewareRibbon.vue';
 import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue';
 import StudipIcon from '../../StudipIcon.vue';
 import { mapActions, mapGetters } from 'vuex';
+import ContentBar from "../../ContentBar.vue";
 
 export default {
     name: 'courseware-search-results',
     components: {
-        CoursewareRibbon,
+        ContentBar,
         CoursewareCompanionBox,
         StudipIcon
     },
diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
index 2b7cdbc610b571c5b25442f6d6d550c5ce0e0841..271af3ec19a6275397bedbd2fe673a1158869d03 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
@@ -8,11 +8,8 @@
             >
                 <div v-if="structuralElement" class="cw-structural-element-content">
                     <courseware-ribbon
-                        :canEdit="canEdit && canAddElements"
-                        :isContentBar="true"
-                        @blockAdded="updateContainerList"
-                    >
-                        <template #buttons>
+                        @blockAdded="updateContainerList">
+                        <template #buttons-left>
                             <router-link v-if="prevElement" :to="'/structural_element/' + prevElement.id">
                                 <div class="cw-ribbon-button cw-ribbon-button-prev" :title="$gettext('zurück')" />
                             </router-link>
@@ -30,66 +27,68 @@
                                 :title="$gettext('Keine nächste Seite')"
                             />
                         </template>
-                        <template #breadcrumbList>
-                            <li
-                                v-for="ancestor in ancestors"
-                                :key="ancestor.id"
-                                :title="ancestor.attributes.title"
-                                class="cw-ribbon-breadcrumb-item"
-                            >
-                                <span>
-                                    <router-link :to="'/structural_element/' + ancestor.id">{{
-                                        ancestor.attributes.title || '–'
-                                    }}</router-link>
-                                </span>
-                            </li>
-                            <li
-                                class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
-                                :title="structuralElement.attributes.title"
-                            >
-                                <span>{{ structuralElement.attributes.title || '–' }}</span>
-                                <span v-if="isTask">[ {{ solverName }} ]</span>
-                                <template v-if="!userIsTeacher && inCourse">
-                                    <studip-icon
-                                        v-if="complete"
-                                        shape="accept"
-                                        role="info"
-                                        :title="$gettext('Diese Seite wurde von Ihnen vollständig bearbeitet')"
-                                    />
-                                    <span
-                                        v-else
+                        <template #breadcrumb-list>
+                            <ul>
+                                <li
+                                    v-for="ancestor in ancestors"
+                                    :key="ancestor.id"
+                                    :title="ancestor.attributes.title"
+                                    class="cw-ribbon-breadcrumb-item"
+                                >
+                                    <span>
+                                        <router-link :to="'/structural_element/' + ancestor.id">{{ ancestor.attributes.title || '–' }}</router-link>
+                                    </span>
+                                </li>
+                                <li
+                                    class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
+                                    :title="structuralElement.attributes.title"
+                                >
+                                    <span>{{ structuralElement.attributes.title || '–' }}</span>
+                                    <span v-if="isTask">[ {{ solverName }} ]</span>
+                                    <template v-if="!userIsTeacher && inCourse">
+                                        <studip-icon
+                                            v-if="complete"
+                                            shape="accept"
+                                            role="info"
+                                            :title="$gettext('Diese Seite wurde von Ihnen vollständig bearbeitet')"
+                                        />
+                                        <span
+                                            v-else
+                                            :title="$gettextInterpolate(
+                                                        $gettext('Fortschritt: %{progress} %'),{
+                                                        progress: elementProgress,
+                                            })
+                                                    "
+                                        >
+                                            ({{ elementProgress }} %)
+                                        </span>
+                                    </template>
+                                    <studip-five-stars
+                                        v-if="showFeedbackInContentbar && hasFeedbackElement"
+                                        :amount="hasFeedbackAverage ? feedbackAverage : 5"
+                                        :size="16"
+                                        :role="hasFeedbackAverage ? 'status-yellow' : 'inactive'"
                                         :title="
-                                            $gettextInterpolate($gettext('Fortschritt: %{progress} %'), {
-                                                progress: elementProgress,
+                                        hasFeedbackAverage
+                                            ?$gettextInterpolate($gettext('Seite wurde mit %{avg} Sternen bewertet'), {
+                                                avg: feedbackAverage,
                                             })
+                                            :$gettext('Seite wurde noch nicht bewertet')
                                         "
-                                    >
-                                        ({{ elementProgress }} %)
-                                    </span>
-                                </template>
-                                <studip-five-stars
-                                    v-if="showFeedbackInContentbar && hasFeedbackElement"
-                                    :amount="hasFeedbackAverage ? feedbackAverage : 5"
-                                    :size="16"
-                                    :role="hasFeedbackAverage ? 'status-yellow' : 'inactive'"
-                                    :title="
-                                        hasFeedbackAverage
-                                            ? $gettextInterpolate($gettext('Seite wurde mit %{avg} Sternen bewertet'), {
-                                                  avg: feedbackAverage,
-                                              })
-                                            : $gettext('Seite wurde noch nicht bewertet')
-                                    "
-                                    @click="menuAction('showFeedback')"
-                                />
-                            </li>
+                                        @click="menuAction('showFeedback')"
+                                    />
+                                </li>
+                            </ul>
                         </template>
-                        <template #breadcrumbFallback>
-                            <li
-                                class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
-                                :title="structuralElement.attributes.title"
-                            >
-                                <span>{{ structuralElement.attributes.title }}</span>
-                            </li>
+                        <template #breadcrumb-fallback>
+                            <ul>
+                                <li
+                                    class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
+                                    :title="structuralElement.attributes.title"
+                                >
+                                    <span>{{ structuralElement.attributes.title }}</span>
+                                </li>
+                            </ul>
                         </template>
                         <template #menu>
                             <studip-action-menu
@@ -440,6 +439,7 @@ import CoursewareStructuralElementDialogPublicLink from './CoursewareStructuralE
 import CoursewareStructuralElementDiscussion from './CoursewareStructuralElementDiscussion.vue';
 
 import CoursewareWelcomeScreen from './CoursewareWelcomeScreen.vue';
+import CoursewareRibbon from "./CoursewareRibbon.vue";
 import CoursewareExport from '@/vue/mixins/courseware/export.js';
 
 import colorMixin from '@/vue/mixins/courseware/colors.js';
@@ -455,6 +455,7 @@ import StudipProgressIndicator from '../../StudipProgressIndicator.vue';
 import draggable from 'vuedraggable';
 import containerMixin from '@/vue/mixins/courseware/container.js';
 import { mapActions, mapGetters } from 'vuex';
+import { store } from "../../../../assets/javascripts/chunks/vue";
 
 export default {
     name: 'courseware-structural-element',
@@ -488,6 +489,7 @@ export default {
         StudipDialog,
         StudipProgressIndicator,
         draggable,
+        CoursewareRibbon,
     }),
     props: ['canVisit', 'orderedStructuralElements', 'structuralElement'],
 
@@ -526,12 +528,14 @@ export default {
     },
 
     computed: {
+        consumeMode() {
+            return store.state.studip.consumeMode;
+        },
         ...mapGetters({
             courseware: 'courseware',
             rootId: 'rootId',
             currentUnit: 'currentUnit',
             context: 'context',
-            consumeMode: 'consumeMode',
             containerById: 'courseware-containers/byId',
             relatedContainers: 'courseware-containers/related',
             relatedStructuralElements: 'courseware-structural-elements/related',
diff --git a/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue b/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue
index e968f7c4f5363fe3dbb0b364b3a563dc59d61719..8f0ec3bc966f1b9e51fed94f8a3acddf8a712482 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue
@@ -25,7 +25,6 @@ export default {
     name: 'courseware-welcome-screen',
     computed: {
         ...mapGetters({
-            consumeMode: 'consumeMode',
             lastCreatedBlocks: 'courseware-blocks/lastCreated',
             lastCreatedContainers: 'courseware-containers/lastCreated'
         }),
@@ -40,13 +39,11 @@ export default {
             lockObject: 'lockObject',
             unlockObject: 'unlockObject',
 
-            coursewareConsumeMode: 'coursewareConsumeMode',
             coursewareContainerAdder: 'coursewareContainerAdder',
             coursewareShowToolbar: 'coursewareShowToolbar'
 
         }),
         addContainer() {
-            this.coursewareConsumeMode(false);
             this.coursewareShowToolbar(true);
             this.$nextTick(() => {
                 this.coursewareContainerAdder(true);
@@ -67,7 +64,6 @@ export default {
                 section: 0,
                 blockType: 'text',
             });
-            this.coursewareConsumeMode(false);
             this.companionSuccess({
                 info: this.$gettext('Das Elemente für Ihren ersten Inhalt wurde angelegt.'),
             });
@@ -76,7 +72,7 @@ export default {
             const structuralElementId = this.$route.params.id
             await this.updateContainer({ container: newContainer, structuralElementId: structuralElementId });
             await this.unlockObject({ id: newContainer.id, type: 'courseware-containers' });
-            
+
 
         }
     }
diff --git a/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue
index 565117198b9357b71945ef8f3f0e4db40a2b583c..a63d1f82d9f37ddb7eefec1fc3e13bc024594352 100644
--- a/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue
+++ b/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue
@@ -5,8 +5,8 @@
             class="cw-structural-element"
         >
             <div v-if="structuralElement" class="cw-structural-element-content">
-                <courseware-ribbon :canEdit="false" :disableSettings="true" :disableAdder="true">
-                    <template #buttons>
+                <ContentBar isContentBar>
+                    <template #buttons-left>
                         <router-link v-if="prevElement" :to="'/structural_element/' + prevElement.id">
                             <div class="cw-ribbon-button cw-ribbon-button-prev" :title="textRibbon.perv" />
                         </router-link>
@@ -16,7 +16,7 @@
                         </router-link>
                         <div v-else class="cw-ribbon-button cw-ribbon-button-next-disabled" :title="$gettext('keine nächste Seite')"/>
                     </template>
-                    <template #breadcrumbList>
+                    <template #breadcrumb-list>
                         <li
                             v-for="ancestor in ancestors"
                             :key="ancestor.id"
@@ -34,7 +34,7 @@
                             <span>{{ structuralElement.attributes.title || "–" }}</span>
                         </li>
                     </template>
-                    <template #breadcrumbFallback>
+                    <template #breadcrumb-fallback>
                         <li
                             class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
                             :title="structuralElement.attributes.title"
@@ -42,7 +42,7 @@
                             <span>{{ structuralElement.attributes.title }}</span>
                         </li>
                     </template>
-                </courseware-ribbon>
+                </ContentBar>
 
                 <div
                     class="cw-container-wrapper"
@@ -79,12 +79,15 @@ import CoursewarePluginComponents from '../plugin-components.js';
 import CoursewareCompanionOverlay from '../layouts/CoursewareCompanionOverlay.vue';
 
 import { mapActions, mapGetters } from 'vuex';
+import ContentBar from "../../ContentBar.vue";
+import { store } from "../../../../assets/javascripts/chunks/vue";
 
 export default {
     name: 'public-courseware-structural-element',
 
     components: Object.assign(StructuralElementComponents, {
         CoursewareCompanionOverlay,
+        ContentBar,
     }),
 
     props: ['orderedStructuralElements', 'structuralElement'],
@@ -100,10 +103,12 @@ export default {
     },
 
     computed: {
+        consumeMode() {
+          return store.state.studip.consumeMode;
+        },
         ...mapGetters({
             courseware: 'courseware',
             context: 'context',
-            consumeMode: 'consumeMode',
             containerById: 'courseware-containers/byId',
             pluginManager: 'pluginManager',
             relatedContainers: 'courseware-containers/related',
diff --git a/resources/vue/components/courseware/structural-element/structural-element-components.js b/resources/vue/components/courseware/structural-element/structural-element-components.js
index 40586c2e683737871649aeb14f48aa81afb94d88..0627eb9a52776e062e863de86c7e281d1275492f 100644
--- a/resources/vue/components/courseware/structural-element/structural-element-components.js
+++ b/resources/vue/components/courseware/structural-element/structural-element-components.js
@@ -1,6 +1,6 @@
 import CoursewareToolbar from './../toolbar/CoursewareToolbar.vue';
 // contentbar
-import CoursewareRibbon from './CoursewareRibbon.vue';
+import ContentBar from "../../ContentBar.vue";
 import CoursewareTabs from '../layouts/CoursewareTabs.vue';
 import CoursewareTab from '../layouts/CoursewareTab.vue';
 import { FocusTrap } from 'focus-trap-vue';
@@ -16,7 +16,7 @@ import CoursewareTabsContainer from '../containers/CoursewareTabsContainer.vue';
 const StructuralElementComponents = {
     CoursewareToolbar,
     //contentbar
-    CoursewareRibbon,
+    ContentBar,
     CoursewareTabs,
     CoursewareTab,
     FocusTrap,
@@ -30,4 +30,4 @@ const StructuralElementComponents = {
     CoursewareTabsContainer
 }
 
-export default StructuralElementComponents;
\ No newline at end of file
+export default StructuralElementComponents;
diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
index 426b0cbff4b36668e11434772c41d1fb5fb4bcd6..2b86c713769c9b1c084626080bd7e03fd66da085 100644
--- a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
@@ -1,17 +1,17 @@
 <template>
     <div class="cw-dashboard-students-wrapper">
-        <CoursewareRibbon :isContentBar="true" :showToolbarButton="false">
-            <template #buttons>
+        <ContentBar isContentBar>
+            <template #buttons-left>
                 <router-link :to="{ name: 'task-groups-index' }">
                     <StudipIcon shape="category-task" :size="24" />
                 </router-link>
             </template>
-            <template #breadcrumbList>
+            <template #breadcrumb-list>
                 <li>
                     {{ $gettext('Aufgaben') }}
                 </li>
             </template>
-        </CoursewareRibbon>
+        </ContentBar>
         <table class="default" v-if="taskGroups.length">
             <thead>
                 <tr class="sortable">
@@ -79,7 +79,6 @@
 import _ from 'lodash';
 import { mapActions, mapGetters } from 'vuex';
 import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
-import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue';
 import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
 import StudipActionMenu from '../../StudipActionMenu.vue';
 import StudipDate from '../../StudipDate.vue';
@@ -88,12 +87,13 @@ import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue';
 import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue';
 import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue';
 import { getStatus } from './task-groups-helper.js';
+import ContentBar from "../../ContentBar.vue";
 
 export default {
     name: 'courseware-dashboard-students',
     components: {
+        ContentBar,
         CompanionBox,
-        CoursewareRibbon,
         CoursewareTasksDialogDistribute,
         StudipActionMenu,
         StudipDate,
@@ -207,12 +207,6 @@ export default {
 </script>
 
 <style scoped>
-.cw-dashboard-students-wrapper >>> .cw-ribbon-nav {
-    min-width: 24px;
-    padding: 0 1em;
-    height: 24px;
-    margin-top: 2px;
-}
 th {
     cursor: pointer;
 }
diff --git a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
index e17d18e60a4701d77f26ac69c0072fb5ee7e40e3..b0530c45da39361c713f037d02683644f09dc61f 100644
--- a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
+++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
@@ -5,13 +5,13 @@
         </MountingPortal>
 
         <div v-if="taskGroup" class="cw-tasks-list">
-            <CoursewareRibbon :isContentBar="true" :showToolbarButton="false">
-                <template #buttons>
+            <ContentBar isContentBar>
+                <template #buttons-left>
                     <router-link :to="{ name: 'task-groups-index' }">
                         <StudipIcon shape="category-task" :size="24" />
                     </router-link>
                 </template>
-                <template #breadcrumbList>
+                <template #breadcrumb-list>
                     <li>
                         <router-link :to="{ name: 'task-groups-index' }">
                             {{ $gettext('Aufgaben') }}
@@ -19,7 +19,7 @@
                     </li>
                     <li>{{ taskGroup.attributes['title'] }}</li>
                 </template>
-            </CoursewareRibbon>
+            </ContentBar>
 
             <TaskGroup
                 :taskGroup="taskGroup"
@@ -67,7 +67,6 @@
 import { mapActions, mapGetters } from 'vuex';
 import AddFeedbackDialog from './AddFeedbackDialog.vue';
 import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
-import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue';
 import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue';
 import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
 import EditFeedbackDialog from './EditFeedbackDialog.vue';
@@ -76,12 +75,13 @@ import TaskGroup from './TaskGroup.vue';
 import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue';
 import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue';
 import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue';
+import ContentBar from "../../ContentBar.vue";
 
 export default {
     components: {
+        ContentBar,
         AddFeedbackDialog,
         CompanionBox,
-        CoursewareRibbon,
         CoursewareTasksActionWidget,
         CoursewareTasksDialogDistribute,
         EditFeedbackDialog,
@@ -215,10 +215,4 @@ export default {
 </script>
 
 <style scoped>
-.cw-tasks-wrapper >>> .cw-ribbon-nav {
-    min-width: 24px;
-    padding: 0 1em;
-    height: 24px;
-    margin-top: 2px;
-}
 </style>
diff --git a/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue b/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue
index 8988a2251ce41e427a2ea2a0bad15a96cc98a359..bbf322c5991ef48f699626b8aec53543749c9a96 100644
--- a/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue
+++ b/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue
@@ -172,7 +172,7 @@ export default {
                 return;
             }
 
-            const ribbon = document.getElementById('cw-ribbon') ?? document.getElementById('contentbar');
+            const ribbon = document.getElementById('cw-ribbon');
             if (ribbon) {
                 const contentbarRect = ribbon.getBoundingClientRect();
                 if (ribbon.classList.contains('cw-ribbon-sticky')) {
diff --git a/resources/vue/components/responsive/ResponsiveContentBar.vue b/resources/vue/components/responsive/ResponsiveContentBar.vue
index eb6dd9636b962fd63422987d01750aa2c3c486dc..d21ae21028c78afa6903d7b9be4ef50cca6f2333 100644
--- a/resources/vue/components/responsive/ResponsiveContentBar.vue
+++ b/resources/vue/components/responsive/ResponsiveContentBar.vue
@@ -63,6 +63,22 @@ export default {
         }
     },
     methods: {
+        onCoursewareContentbarMounted(vueInstance) {
+            STUDIP.eventBus.emit('has-contentbar', true);
+
+            this.realContentbar = vueInstance.$refs.header;
+            this.realContentbarSource = vueInstance.$refs.headerContainer;
+            this.realContentbarIconContainer = '.cw-ribbon-nav';
+            this.realContentbarType = 'courseware';
+            this.adjustExistingContentbar(true);
+
+            document.querySelectorAll('.sidebar-widget button span').forEach(item => {
+                item.addEventListener('click', () => this.toggleSidebar());
+            });
+        },
+        onCoursewareContentbarBeforeDestroy(vueInstance) {
+            this.adjustExistingContentbar(false);
+        },
         toggleSidebar() {
 
             const sidebar = document.getElementById('sidebar');
@@ -75,8 +91,8 @@ export default {
 
                 if (document.documentElement.classList.contains('responsive-display')
                         && !document.documentElement.classList.contains('fullscreen-mode')) {
-                    content.style.display = '';
-                    pageTitle.style.display = '';
+                    content.style.visibility = '';
+                    pageTitle.style.visibility = '';
                 }
 
                 if (!document.documentElement.classList.contains('responsive-display')) {
@@ -95,8 +111,8 @@ export default {
                     && !document.documentElement.classList.contains('fullscreen-mode')) {
                     // Set a timeout here so that the content "disappears" after slide-in aninmation is finished.
                     setTimeout(() => {
-                        content.style.display = 'none';
-                        pageTitle.style.display = 'none';
+                        content.style.visibility = 'hidden';
+                        pageTitle.style.visibility = 'hidden';
                     }, 300);
                 }
 
@@ -134,11 +150,9 @@ export default {
                     const contentbarContainer = document.getElementById('responsive-contentbar-container');
 
                     contentbarContainer.prepend(this.realContentbar);
-
-                    document.getElementById('content-wrapper').style.marginTop = `${contentbarContainer.clientHeight}px`;
                 } else {
-                    this.realContentbar.id = 'contentbar';
-                    document.getElementById('toggle-sidebar').remove();
+                    this.realContentbar.id = 'cw-ribbon';
+                    document.getElementById('toggle-sidebar')?.remove();
 
                     if (this.realContentbarType === 'courseware') {
                         this.realContentbar.classList.remove('contentbar');
@@ -148,9 +162,7 @@ export default {
                             .classList.remove('contentbar-wrapper-right');
                     }
 
-                    document.querySelector(this.realContentbarSource).prepend(this.realContentbar);
-
-                    document.getElementById('content-wrapper').style.marginTop = 'initial';
+                    this.realContentbarSource.append(this.realContentbar);
                 }
             }
         },
@@ -196,7 +208,7 @@ export default {
                     STUDIP.eventBus.emit('has-contentbar', true);
 
                     this.realContentbar = cwContentbar;
-                    this.realContentbarSource = '.cw-structural-element-content > div';
+                    this.realContentbarSource = cwContentbar.parentElement;
                     this.realContentbarIconContainer = '.cw-ribbon-nav';
                     this.realContentbarType = 'courseware';
                     this.adjustExistingContentbar(true);
@@ -210,35 +222,16 @@ export default {
         })
 
         // Use courseware contentbar instead of this Vue component.
-        this.globalOn('courseware-contentbar-mounted', element => {
-            STUDIP.eventBus.emit('has-contentbar', true);
-
-            this.realContentbar = element.$el.querySelector('header');
-            this.realContentbarSource = '.cw-structural-element-content > div';
-            this.realContentbarIconContainer = '.cw-ribbon-nav';
-            this.realContentbarType = 'courseware';
-            this.adjustExistingContentbar(true);
-
-            document.querySelectorAll('.sidebar-widget button span').forEach(item => {
-                item.addEventListener('click', () => this.toggleSidebar());
-            });
-        })
-
-        this.globalOn('toggle-focus-mode', (state) => {
-            const html = document.querySelector('html');
-            if (html.classList.contains('responsive-display') || html.classList.contains('fullscreen-mode')) {
-                this.adjustExistingContentbar(!state);
-            }
-        });
-
+        this.globalOn('courseware-contentbar-mounted', this.onCoursewareContentbarMounted)
+        this.globalOn('courseware-contentbar-before-destroy', this.onCoursewareContentbarBeforeDestroy);
     },
     beforeDestroy() {
+        this.globalOff('courseware-contentbar-mounted', this.onCoursewareContentbarMounted);
+        this.globalOff('courseware-contentbar-before-destroy', this.onCoursewareContentbarBeforeDestroy);
         if (this.realContentbar) {
             this.adjustExistingContentbar(false);
         }
 
-        STUDIP.eventBus.off('toggle-focus-mode');
-        STUDIP.eventBus.off('courseware-contentbar-mounted');
     }
 }
 </script>
diff --git a/resources/vue/components/table-of-contents.ts b/resources/vue/components/table-of-contents.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9dd74dd0cdfe66e49a2551ec400f3b61ae2f4672
--- /dev/null
+++ b/resources/vue/components/table-of-contents.ts
@@ -0,0 +1,19 @@
+import { Icon } from '../../studip';
+
+// Corresponds to the PHP class TOCItem
+export interface TOCItem {
+    title: string;
+    url: string;
+    parent?: TOCItem;
+    children: TOCItem[];
+    active: boolean;
+    icon?: Icon;
+}
+
+// Depth-first traversal of a TOCItem hierachy starting at its root.
+export function traverse(tocRoot: TOCItem, callback: (item: TOCItem) => void) {
+    callback(tocRoot);
+    for (let tocItem of tocRoot.children) {
+        traverse(tocItem, callback);
+    }
+}
diff --git a/resources/vue/mixins/courseware/export.js b/resources/vue/mixins/courseware/export.js
index 3d9e33ab4e8848e90429463bffa66a39403e12d8..f3002c3bc287d28c2abee0229f3d891e8ffb593d 100644
--- a/resources/vue/mixins/courseware/export.js
+++ b/resources/vue/mixins/courseware/export.js
@@ -196,6 +196,7 @@ export default {
             formData.append("data[difficulty_start]", difficulty_start);
             formData.append("data[difficulty_end]", difficulty_end);
             formData.append("data[category]", 'elearning');
+            formData.append(STUDIP.CSRF_TOKEN.name, STUDIP.CSRF_TOKEN.value);
 
             axios({
                 method: 'post',
@@ -256,13 +257,13 @@ export default {
                 if (fileType === 'file-refs') {
                     await this.loadFileRefsById({id: fileId});
                     let fileRef = this.fileRefsById({id: fileId});
-                    
+
                     let fileRefData = {};
                     fileRefData.id = fileRef.id;
                     fileRefData.attributes = fileRef.attributes;
                     fileRefData.related_element_id = element.id;
                     fileRefData.folder = null;
-    
+
                     this.exportFiles.json.push(fileRefData);
                     this.exportFiles.download[fileRef.id] = {
                         folder: null,
diff --git a/resources/vue/store/StudipStore.js b/resources/vue/store/StudipStore.js
index 7a733c08d40dacc97b8d29fa13fbfe513a5637fe..7c404a4f18b9037f094b2f2d1b24c9698fccc4cb 100644
--- a/resources/vue/store/StudipStore.js
+++ b/resources/vue/store/StudipStore.js
@@ -1,8 +1,10 @@
-export default {
+import { eventBus, store } from '../../assets/javascripts/chunks/vue';
+
+const studipStore = {
     namespaced: true,
 
-    state () {
-        return {...STUDIP.config};
+    state() {
+        return { ...STUDIP.config, consumeMode: false };
     },
     getters: {
         getConfig: (state) => (key) => {
@@ -10,6 +12,13 @@ export default {
                 throw new Error(`Invalid access to unknown configuration item "${key}"`);
             }
             return state[key];
-        }
-    }
-}
+        },
+    },
+};
+
+// Make the current state of "focus mode" (fullscreen) available to Vue components.
+eventBus.on('switch-focus-mode', (mode) => {
+    store.state.studip.consumeMode = mode;
+});
+
+export default studipStore;
diff --git a/resources/vue/store/courseware/courseware-public.module.js b/resources/vue/store/courseware/courseware-public.module.js
index 1982fe28fcf9e6ce7318145344e87f8941663da7..403cfe451fe4b6fc91963ba5bba97165cd0fad12 100644
--- a/resources/vue/store/courseware/courseware-public.module.js
+++ b/resources/vue/store/courseware/courseware-public.module.js
@@ -1,7 +1,6 @@
 const getDefaultState = () => {
     return {
         blockAdder: {},
-        consumeMode: true,
         containerAdder: false,
         context: null,
         courseware: {},
@@ -22,10 +21,6 @@ const getters = {
         return state.blockAdder;
     },
 
-    consumeMode(state) {
-        return state.consumeMode;
-    },
-
     containerAdder(state) {
         return state.containerAdder;
     },
@@ -71,10 +66,6 @@ export const state = { ...initialState };
 
 export const actions = {
     // setters
-    coursewareConsumeMode({ commit }, consumeMode) {
-        commit('setConsumeMode', consumeMode);
-    },
-
     coursewareContainerAdder(context, adder) {
         context.commit('setContainerAdder', adder);
     },
@@ -134,10 +125,6 @@ export const mutations = {
         state.courseware = data;
     },
 
-    setConsumeMode(state, consumeMode) {
-        state.consumeMode = consumeMode;
-    },
-
     setContainerAdder(state, containerAdder) {
         state.containerAdder = containerAdder;
     },
diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js
index 53ef6aa396de1f19c953746a8dc6c33513be1ee1..cb844e15abca66548fecc29ae5c69662a7ddfd17 100644
--- a/resources/vue/store/courseware/courseware.module.js
+++ b/resources/vue/store/courseware/courseware.module.js
@@ -989,10 +989,6 @@ export const actions = {
         context.commit('coursewareStyleCompanionOverlaySet', companion_overlay_style);
     },
 
-    coursewareConsumeMode(context, mode) {
-        context.commit('coursewareConsumeModeSet', mode);
-    },
-
     setHttpClient({ commit }, httpClient) {
         commit('setHttpClient', httpClient);
     },
@@ -1581,10 +1577,6 @@ export const mutations = {
         state.styleCompanionOverlay = data;
     },
 
-    coursewareConsumeModeSet(state, data) {
-        state.consumeMode = data;
-    },
-
     setHttpClient(state, httpClient) {
         state.httpClient = httpClient;
     },
diff --git a/templates/vue-app.php b/templates/vue-app.php
index 2c34929f9bdb6098241c0171309a4463efa586c0..7f0841ba093a6ffaffc7fe6f227f41456ada70a4 100644
--- a/templates/vue-app.php
+++ b/templates/vue-app.php
@@ -4,11 +4,16 @@
  * @var string $baseComponent
  * @var array $props
  * @var array $storeData
+ * @var array $slots
  */
 ?>
 <? foreach ($storeData as $store => $data): ?>
 <script type="application/json" id="vue-store-data-<?= htmlReady($store) ?>"><?= json_encode($data) ?></script>
 <? endforeach; ?>
 <div <?= arrayToHtmlAttributes($attributes) ?>>
-    <<?= strtokebabcase($baseComponent) ?> <?= arrayToHtmlAttributes($props) ?>/>
+    <<?= strtokebabcase($baseComponent) ?> <?= arrayToHtmlAttributes($props) ?>>
+    <? foreach ($slots as $slotname => $slot): ?>
+        <template #<?= htmlReady($slotname) ?>><?= $slot ?></template>
+    <? endforeach; ?>
+    </<?= strtokebabcase($baseComponent) ?>>
 </div>