diff --git a/lib/visual.inc.php b/lib/visual.inc.php
index 7b7ef101472ecbb0a6630963b3ba08643e9f6e98..08026064a73e3a96cd99f88d1faa1771ea027eff 100644
--- a/lib/visual.inc.php
+++ b/lib/visual.inc.php
@@ -596,12 +596,7 @@ function tooltipIcon($text, $important = false, $html = false): string
 
     // render tooltip
     $template = $GLOBALS['template_factory']->open('shared/tooltip');
-    return $template->render([
-        'text'       => $text,
-        'important'  => $important,
-        'html'       => $html,
-        'tooltip_id' => md5($text)
-    ]);
+    return $template->render(compact('text', 'important', 'html'));
 }
 
 /**
@@ -613,13 +608,9 @@ function tooltipIcon($text, $important = false, $html = false): string
 function tooltipHtmlIcon($text, $important = false)
 {
     // render tooltip
+    $html = true;
     $template = $GLOBALS['template_factory']->open('shared/tooltip');
-    return $template->render([
-        'text'       => $text,
-        'important'  => $important,
-        'html'       => true,
-        'tooltip_id' => md5($text)
-    ]);
+    return $template->render(compact('text', 'important', 'html'));
 }
 
 /**
diff --git a/resources/assets/javascripts/bootstrap/tooltip.js b/resources/assets/javascripts/bootstrap/tooltip.js
new file mode 100644
index 0000000000000000000000000000000000000000..4b17cb0604e1f794b138f43df7d47669fea3025c
--- /dev/null
+++ b/resources/assets/javascripts/bootstrap/tooltip.js
@@ -0,0 +1,72 @@
+// Attach global hover handler for tooltips.
+// Applies to all elements having a "data-tooltip" attribute.
+// Tooltip may be provided in the data-attribute itself or by
+// defining a title attribute. The latter is prefered due to
+// the obvious accessibility issues.
+
+let timeout = null;
+
+STUDIP.Tooltip.threshold = 6;
+
+$(document).on('mouseenter mouseleave focusin focusout', '[data-tooltip],.tooltip:has(.tooltip-content)', function(event) {
+    let data = $(this).data();
+
+    const visible = ['mouseenter', 'focusin'].includes(event.type);
+    const offset = $(this).offset();
+    const x = offset.left + $(this).outerWidth(true) / 2;
+    const y = offset.top;
+    const delay = data.tooltipDelay ?? 300;
+
+    let content;
+    let tooltip;
+
+    if (!data.tooltipObject) {
+        // If tooltip has not yet been created (first hover), obtain it's
+        // contents and create the actual tooltip object.
+        if (!data.tooltip || !$.isPlainObject(data.tooltip)) {
+            content = $('<div/>').text(data.tooltip || $(this).attr('title')).html();
+        } else if (data.tooltip.html !== undefined) {
+            content = data.tooltip.html;
+        } else if (data.tooltip.text !== undefined) {
+            content = data.tooltip.text;
+        } else {
+            throw "Invalid content for tooltip via data";
+        }
+        if (!content) {
+            content = $(this).find('.tooltip-content').remove().html();
+        }
+        $(this).attr('title', null);
+        $(this).attr('data-tooltip', content);
+
+        tooltip = new STUDIP.Tooltip(x, y, content);
+
+        data.tooltipObject = tooltip;
+
+        if (!this.hasAttribute('aria-label')) {
+            const div = document.createElement('div');
+            div.innerHTML = content;
+            this.setAttribute('aria-label', div.innerText.trim());
+        }
+
+        $(this).on('remove', function() {
+            tooltip.remove();
+        });
+    } else if (visible) {
+        // If tooltip has already been created, update it's position.
+        // This is neccessary if the surrounding content is scrollable AND has
+        // been scrolled. Otherwise the tooltip would appear at it's previous
+        // and now wrong location.
+        data.tooltipObject.position(x, y);
+    }
+
+    if (visible) {
+        $('.studip-tooltip').not(data.tooltipObject).hide();
+        data.tooltipObject.show();
+    } else {
+        timeout = setTimeout(() => data.tooltipObject.hide(), delay);
+    }
+}).on('mouseenter focusin', '.studip-tooltip', () => {
+    clearTimeout(timeout);
+}).on('mouseleave focusout', '.studip-tooltip', function() {
+    $(this).hide();
+});
diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js
index 5f88c3069ba8f098cad208ad8221efd838bd01ce..f551c6dadbb67502633b14d76afb7b61e2cf1722 100644
--- a/resources/assets/javascripts/entry-base.js
+++ b/resources/assets/javascripts/entry-base.js
@@ -57,6 +57,7 @@ import "./bootstrap/article.js"
 import "./bootstrap/copyable_links.js"
 import "./bootstrap/selection.js"
 import "./bootstrap/data_secure.js"
+import "./bootstrap/tooltip.js"
 import "./bootstrap/lightbox.js"
 import "./bootstrap/application.js"
 import "./bootstrap/global_search.js"
diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js
index 1d7d5ac2ee21e5da0949c2aa09c86aed75b00aa6..3a8402e4acaf65964edb944f532c7b41d6354b9d 100644
--- a/resources/assets/javascripts/init.js
+++ b/resources/assets/javascripts/init.js
@@ -75,6 +75,7 @@ import Statusgroups from './lib/statusgroups.js';
 import study_area_selection from './lib/study_area_selection.js';
 import Table from './lib/table.js';
 import TableOfContents from './lib/table-of-contents.js';
+import Tooltip from './lib/tooltip.js';
 import Tour from './lib/tour.js';
 import * as Gettext from './lib/gettext';
 import UserFilter from './lib/user_filter.js';
@@ -160,6 +161,7 @@ window.STUDIP = _.assign(window.STUDIP || {}, {
     study_area_selection,
     Table,
     TableOfContents,
+    Tooltip,
     Tour,
     URLHelper,
     UserFilter,
diff --git a/resources/assets/javascripts/lib/tooltip.js b/resources/assets/javascripts/lib/tooltip.js
new file mode 100644
index 0000000000000000000000000000000000000000..2cdac27be88aaa69b6fa0cdba5cfae2af8e9c826
--- /dev/null
+++ b/resources/assets/javascripts/lib/tooltip.js
@@ -0,0 +1,227 @@
+import CSS from './css.js';
+
+/**
+ * Tooltip library for Stud.IP
+ *
+ * @author Jan-Hendrik Willms <tleilax+studip@gmail.com>
+ * @copyright Stud.IP Core Group 2014
+ * @license GPL2 or any later version
+ * @since Stud.IP 3.1
+ */
+
+let count = 0;
+let threshold = 0;
+
+class Tooltip {
+    static get count() {
+        return count;
+    }
+
+    static set count(value) {
+        count = value;
+    }
+
+    // Threshold used for "edge detection" (imagine a padding along the edges)
+    static get threshold() {
+        return threshold;
+    }
+
+    static set threshold(value) {
+        threshold = value;
+    }
+
+    /**
+     * Returns a new unique id of a tooltip.
+     *
+     * @return {string} Unique id
+     * @static
+     */
+    static getId() {
+        const id = `studip-tooltip-${Tooltip.count}`;
+        Tooltip.count += 1;
+        return id;
+    }
+
+    /**
+     * Constructs a new tooltip at given location with given content.
+     * The applied css class may be changed by the fourth parameter.
+     *
+     * @class
+     * @classdesc Stud.IP tooltips provide an improved layout and handling
+     *            of contents (including html) than the browser's default
+     *            tooltip through title attribute would
+     *
+     * @param {int} x - Horizontal position of the tooltip
+     * @param {int} y - Vertical position of the tooltip
+     * @param {string} content - Content of the tooltip (may be html)
+     * @param {string} css_class - Optional name of the applied css class /
+     *                             defaults to 'studip-tooltip'
+     */
+    constructor(x, y, content, css_class) {
+        // Obtain unique id of the tooltip
+        this.id = Tooltip.getId();
+
+        // Create dom element of the tooltip, apply id and class and attach
+        // to dom
+        this.element = $('<div>');
+        this.element.addClass(css_class || 'studip-tooltip');
+        this.element.attr('id', this.id);
+        this.element.attr('role', 'tooltip');
+        this.element.appendTo('body');
+
+        // Set position and content and paint the tooltip
+        this.position(x, y);
+        this.update(content);
+        this.paint();
+    }
+
+    /**
+     * Translates the arrow(s) under a tooltip using css3 translate
+     * transforms. This is needed at the edges of the screen.
+     * This implies that a current browser is used. The translation could
+     * also be achieved by adjusting margins but that way we would need
+     * to hardcode values into this function since it's a struggle to
+     * obtain the neccessary values from the CSS pseudo selectors in JS.
+     *
+     * Internal, css rules are dynamically created and applied to the current
+     * document by using the methods provided in the file studip-css.js.
+     *
+     * @param {int} x - Horizontal offset
+     * @param {int} y - Vertical offset
+     */
+    translateArrows(x, y, left_arrow = false) {
+        CSS.removeRule(`#${this.id}::before`);
+        CSS.removeRule(`#${this.id}::after`);
+
+        if (x !== 0 || y !== 0) {
+            let before_rule = {
+                transform: `translate(${x}px, ${y}px);`
+            };
+            if (left_arrow) {
+                before_rule.transform = `translate(${x}px, ${y}px) rotate(90deg);`;
+            }
+            let after_rule = before_rule;
+            if (left_arrow) {
+                after_rule['border-width'] = '9px';
+            }
+            CSS.addRule(`#${this.id}::before`, before_rule, ['-ms-', '-webkit-']);
+            CSS.addRule(`#${this.id}::after`, after_rule, ['-ms-', '-webkit-']);
+        }
+    }
+
+    /**
+     * Updates the position of the tooltip.
+     *
+     * @param {int} x - Horizontal position of the tooltip
+     * @param {int} y - Vertical position of the tooltip
+     */
+    position(x, y) {
+        this.x = x;
+        this.y = y;
+    }
+
+    /**
+     * Updates the contents of the tooltip.
+     *
+     * @param {string} content - Content of the tooltip (may be html)
+     */
+    update(content) {
+        this.element.html(content);
+    }
+
+    /**
+     * "Paints" the tooltip. This method actually computes the dimensions of
+     * the tooltips, checks for screen edges and calculates the actual offset
+     * in the current document.
+     * This method is neccessary due to the fact that position and content
+     * can be changed apart from each other.
+     * Thus: Don't forget to repaint after adjusting any of the two.
+     */
+    paint() {
+        const width = this.element.outerWidth(true);
+        const height = this.element.outerHeight(true);
+        const maxWidth = $(document).width();
+        const maxHeight = $(document).height();
+        let x = this.x - width / 2;
+        let y = this.y - height;
+        //The arrow offset is the offset from the bottom right corner of
+        //the tooltip "frame".
+        let arrow_offset_x = 0;
+        let arrow_offset_y = 0;
+        let left_arrow = false;
+
+        if (y < 0) {
+            y = 0;
+            x = this.x + 20;
+            //Put the arrow on the left side and move the tooltip,
+            //if there is still enough place left on the right.
+            left_arrow = true;
+            arrow_offset_y = -height + this.y + 10;
+            if (arrow_offset_y > -20) {
+                y+= arrow_offset_y + 20;
+                arrow_offset_y = -20;
+            }
+            arrow_offset_x = -width / 2 - 8;
+        } else if (y + height > maxHeight) {
+            y = maxHeight - height;
+        }
+
+        if (x < 0) {
+            arrow_offset_x = 0;
+            x = 0;
+        } else if (x + width > maxWidth) {
+            arrow_offset_x = x + width - maxWidth;
+            x = maxWidth - width;
+        }
+        this.translateArrows(arrow_offset_x, arrow_offset_y, left_arrow);
+
+        this.element.css({
+            left: x,
+            top: y
+        });
+    }
+
+    /**
+     * Toggles the visibility of the tooltip. If no state is provided,
+     * the tooltip will be hidden if visible and vice versa. Pretty straight
+     * forward and no surprises here.
+     * This method implicitely calls paint before a tooltip is shown (in case
+     * it was forgotten).
+     *
+     * @param {bool} visible - Optional visibility parameter to set the
+     *                         tooltip to a certain state
+     */
+    toggle(visible) {
+        if (visible) {
+            this.paint();
+        }
+        this.element.toggle(visible);
+    }
+
+    /**
+     * Reveals the tooltip.
+     *
+     * @see Tooltip.toggle
+     */
+    show() {
+        this.toggle(true);
+    }
+
+    /**
+     * Hides the tooltip.
+     *
+     * @see Tooltip.toggle
+     */
+    hide() {
+        this.toggle(false);
+    }
+
+    /**
+     * Removes the tooltip
+     */
+    remove() {
+        this.element.remove();
+    }
+}
+
+export default Tooltip;
diff --git a/resources/assets/stylesheets/scss/tooltip.scss b/resources/assets/stylesheets/scss/tooltip.scss
index ac570a86388920572befecd8ad2fc993de08099e..873eeede2382ebe456416652b1bdd73d6b7d1b5a 100644
--- a/resources/assets/stylesheets/scss/tooltip.scss
+++ b/resources/assets/stylesheets/scss/tooltip.scss
@@ -7,7 +7,7 @@
     box-shadow: 0 1px 0 fade-out($white, 0.5) inset;
     font-size: var(--font-size-base);
     margin-bottom: 8px;
-    max-width: $grid-element-width;
+    max-width: 230px;
     padding: 10px;
     position: absolute;
     text-align: left;
@@ -24,6 +24,7 @@
 }
 
 .tooltip {
+    cursor: help;
     display: inline-block;
     position: relative;
 
@@ -38,13 +39,14 @@
         @extend %tooltip;
         display: none;
     }
-
-    &:hover .tooltip-content,
-    &:focus .tooltip-content {
-        bottom: 100%;
-        display: inline-block;
-        left: 50%;
-        margin-left: - calc($grid-element-width / 2) - 10px;
-        width: $grid-element-width;
+    &:hover,
+    &:focus {
+        .tooltip-content {
+            bottom: 100%;
+            display: inline-block;
+            left: 50%;
+            margin-left: -129px;
+            width: 230px;
+        }
     }
 }
diff --git a/resources/vue/components/StudipTooltipIcon.vue b/resources/vue/components/StudipTooltipIcon.vue
index 30d203345224287ded158beeb5353ade4632dc26..ac0f59c6cf8e3961ad9d4644f43ccf8372e19c9a 100644
--- a/resources/vue/components/StudipTooltipIcon.vue
+++ b/resources/vue/components/StudipTooltipIcon.vue
@@ -1,7 +1,7 @@
 <template>
-    <span data-tooltip class="tooltip tooltip-icon" :class="cssClass" :title="title" tabindex="0">
+    <span data-tooltip class="tooltip tooltip-icon" :class="cssClass" tabindex="0" :aria-label="cleanText">
         <span class="tooltip-content" v-if="isHtml" v-html="text"></span>
-        <studip-icon shape="info-circle" role="inactive" :size="size"></studip-icon>
+        <span class="tooltip-content" v-else>{{ text }}</span>
     </span>
 </template>
 
@@ -26,21 +26,14 @@
             }
         },
         computed: {
+            cleanText() {
+                const div = document.createElement('div');
+                div.innerHTML = this.text;
+                return div.innerText.trim();
+            },
             cssClass () {
                 return this.isImportant ? 'tooltip-important' : '';
-            },
-            title () {
-                return !this.isHtml ? this.text : '';
             }
         }
     }
 </script>
-
-<style lang="scss" scoped>
-.tooltip img {
-    vertical-align: text-bottom;
-}
-.tooltip.tooltip-icon::before {
-    display: none;
-}
-</style>
diff --git a/templates/shared/tooltip.php b/templates/shared/tooltip.php
index 513700f6f20cc9c77c66e5ba89e09f84f0164baa..207eaa2779c3e4d8b699997eb62d17594c62c670 100644
--- a/templates/shared/tooltip.php
+++ b/templates/shared/tooltip.php
@@ -5,7 +5,10 @@
  * @var string $text
  */
 ?>
-<span class="tooltip tooltip-icon <? if ($important) echo 'tooltip-important'; ?>"
-      tabindex="0" aria-label="<?= $html ? htmlReady(strip_tags($text)) : htmlReady($text) ?>">
+<span class="as-link tooltip tooltip-icon <? if ($important) echo 'tooltip-important'; ?>"
+      tabindex="0"
+      data-tooltip
+      aria-label="<?= htmlReady($html ? strip_tags($text) : $text) ?>"
+>
     <span class="tooltip-content"><?= $html ? $text : htmlReady($text) ?></span>
 </span>