Skip to content Home - me2 Accessibility logo

Accessible Infotip

Infotips for mouse, keyboard, and screen reader users

Infotip

An infotip consists of two parts:

  • Trigger element.
  • Floating text bubble.

The floating text bubble can display when the trigger element is:

  • Hovered over with the mouse.
  • Focused via keyboard.
  • Activated (by mouse click, by tap, or via the Enter or Spacebar key when the trigger has keyboard focus).

Here is an example infotip: An infotip consists of a trigger and a floating text bubble, this is the bubble.

For compliance to WCAG 2.2, the following four success criteria are especially relevant:

  • 2.1.1 Keyboard : the keyboard focus can be moved to the trigger and the infotip displayed via keyboard. The infotip can display directly on keyboard focus or when the Enter or Spacebar keys are pressed.
  • 4.1.2 Name, Role, Value : the infotip is accessible via screen reader and it is conveyed via text or markup that the content is an infotip.
  • 1.4.13 Content on Hover or Focus : for infotips that expand on mouse hover or keyboard focus: the mouse cursor can be moved across the infotip without the infotip closing. In addition, pressing the Escape key closes the infotip.
  • 2.4.11 Focus Not Obscured (Minimum) : for infotips that expand on mouse click or keyboard press (Enter or Spacebar keys), pressing the Escape key closes the infotip.

The implementations below are three examples of WCAG compliant infotips.

Button trigger infotip

This infotip is implemented as typical expanding content: the trigger is a button with an 'aria-expanded' attribute and the expanding content (displayed as a floating bubble) is placed in the DOM sequence immediately after the trigger. When the infotip is displayed, the value of 'aria-expanded' is "true" and when closed the value is "false". The trigger buttons label is "infotip".

Note that the functionality of the trigger button is solely to display/close the infotip.

Example: An infotip consists of a trigger and a floating text bubble, this is the bubble.

Markup and code example

HTML

<p>
    Example:
    <span class="infotip2">
        <!-- SVG expands when bubble is displayed, so mouseleave is not triggered when moving cursor from trigger to bubble -->
        <svg class="back" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" style="left: 0px; top: 0px; width: 18px; height: 18px;">
            <path class="bubble" d="M0.843,5.196 C1.852,3.033 3.679,1.359 5.922,0.543 8.165,-0.274 10.64,-0.166 12.804,0.843 14.967,1.852 16.641,3.679 17.457,5.922 18.274,8.165 18.166,10.64 17.157,12.804 16.148,14.967 14.321,16.641 12.078,17.457 9.835,18.274 7.36,18.166 5.196,17.157 3.033,16.148 1.359,14.321 0.543,12.078 -0.274,9.835 -0.166,7.36 0.843,5.196">
                <animate class="show" attributeName="d" begin="indefinite" dur=".2" repeatCount="1" fill="freeze" from="M0.843,5.196 C1.852,3.033 3.679,1.359 5.922,0.543 8.165,-0.274 10.64,-0.166 12.804,0.843 14.967,1.852 16.641,3.679 17.457,5.922 18.274,8.165 18.166,10.64 17.157,12.804 16.148,14.967 14.321,16.641 12.078,17.457 9.835,18.274 7.36,18.166 5.196,17.157 3.033,16.148 1.359,14.321 0.543,12.078 -0.274,9.835 -0.166,7.36 0.843,5.196" to="M27,-3 c0,-39 0,-39 39,-39 0,0 82,0 82,0 39,0 39,0 39,39 0,0 0,24 0,24 0,39 0,39 -39,39 0,0 -82,0 -82,0 -39,0 -39,0 -39,-39 0,0 0,-24 0,-24 "></animate>
                <animate class="hide" attributeName="d" begin="indefinite" dur=".2" repeatCount="1" fill="freeze" from="M27,-3 c0,-39 0,-39 39,-39 0,0 82,0 82,0 39,0 39,0 39,39 0,0 0,24 0,24 0,39 0,39 -39,39 0,0 -82,0 -82,0 -39,0 -39,0 -39,-39 0,0 0,-24 0,-24 " to="M0.843,5.196 C1.852,3.033 3.679,1.359 5.922,0.543 8.165,-0.274 10.64,-0.166 12.804,0.843 14.967,1.852 16.641,3.679 17.457,5.922 18.274,8.165 18.166,10.64 17.157,12.804 16.148,14.967 14.321,16.641 12.078,17.457 9.835,18.274 7.36,18.166 5.196,17.157 3.033,16.148 1.359,14.321 0.543,12.078 -0.274,9.835 -0.166,7.36 0.843,5.196"></animate>
            </path>
        </svg>
        <button aria-label="Infotip" aria-expanded="false">
            <svg>
                <circle class="circle" cx="9" cy="9" r="9"></circle>
                <path class="mark" d="M 5.716,6.203 Q 7.054,3.527 9.486,3.77 11.432,4.014 11.919,5.838 12.284,7.419 10.338,8.514 9,9.243 9,11.189 "></path>
                <circle class="dot" cx="9" cy="14.108" r="1.5"></circle>
            </svg>
        </button>
        <span class="con" style="width: 160px; left: 27px; top: -42px; visibility: hidden;">
            An
            <a href="https://en.wiktionary.org/wiki/infotip" target="_blank">
                infotip
            </a>
            consists of a trigger and a floating text bubble, this is the bubble.
        </span>
    </span>
</p>

CSS

span.infotip2{
    position:relative;
    top:3px;
    display:inline-flex;
    padding:0;
    margin:0;
    width:18px;
    height:18px;

    > button{
        position:absolute;
        padding:0;
        margin:0;
        width:18px;
        height:18px;
        border:0;
        border-radius:9px;
        outline-offset:2px;

        svg{
            width:18px;
            height:18px;

            path.bubble {
                fill: #292929;
            }
            circle.circle {
                fill: #292929;
                cursor:pointer;
            }
            path.mark {
                stroke:#FFFFFF;
                stroke-width:2.6;
                fill:none;
                cursor:pointer;
            }
            circle.dot{
                fill:#FFFFFF;
                cursor:pointer;
            }
        }
    }

    svg.back{
        position:absolute;
        left:0;
        bottom:0;
        cursor:auto;
    }

    span.con{
        z-index:99;
        position:absolute;
        display:block;
        visibility:hidden;
        width:fit-content;
        height:fit-content;
        max-height:fit-content;
        padding:9px 4px 9px 11px;
        font-size:13px;
        font-family:Verdana;
        color:#FFF;

        a {
            color:white;
        }
    }
}

JavaScript

{

const It = document.querySelector("span.infotip2");

const Btt = It.querySelector("button");

const Con = It.querySelector("span.con");

const SVG = It.querySelector("svg.back");

const Show = SVG.querySelector("animate.show");

const Hide = SVG.querySelector("animate.hide");

const Bub = SVG.querySelector("path.bubble");

//Content <span>. The width is fixed
let ConX;
let ConY;
const ConW = 160;
let ConH;

Con.style.width = ConW + "px";

//SVG
let SvgX;
let SvgY;
let SvgW;
let SvgH;

//Space between button and content/bubble
const Space = 9;
//Button width & height
const Bwh = 18;

//Prevent trigger of expand/collapse mid-animation
let Moving = false;
//Prevent mouse hover functionality when display of help tip is by clicking the button
let NoMouse = false;

//Stats (show/hide) is changed by: button-click, mouseenter, mouseleave, Escape-key, and page-click
//Each event handler checks the state of aria-expanded so showTip() and hideTip() do not need to check this,
//showTip() and hideTip() just set the state of aria-expanded
Btt.addEventListener("click",()=>{
    if(Btt.getAttribute("aria-expanded") === "false"){
        showTip(true);
    }else{
        hideTip();
    }
});

It.addEventListener("mouseenter",()=>{
//Delay show on mousehover a little bit so it doesn't pop up when user is just moving cursor across screen
    setTimeout(function(){
        if(NoMouse){return}
        //After delay, only show if cursor is still over trigger
        if([...It.parentNode.querySelectorAll(":hover")].includes(It)){
            if(Btt.getAttribute("aria-expanded") === "false"){
                showTip();
            }
        }
    },50);
});

It.addEventListener("mouseleave",()=>{
    if(NoMouse){return}
    if(Btt.getAttribute("aria-expanded") === "true"){
        hideTip();
    }
});

//The following two functions are only active when the tip is open, so no need to check the value of 'aria-expanded'
const keyClose = function(ev){
    if(ev.key === "Escape"){
        hideTip();
    }
}

const mouseClose = function(ev){
    //Ignore any click on the help tip
    //except if the click is on the SVG between button and bubble.
    if(It.contains(ev.target) && ev.target !== SVG){
        return;
    }
    hideTip();
}

//================================================
const showTip = function(SetNoMouse){

    if(Moving){return}
    Moving = true;
    if(SetNoMouse){
        NoMouse = true;
    }
    Btt.setAttribute("aria-expanded","true");

    ConH = Con.offsetHeight;

    const winW = window.innerWidth;

    const bcl = It.getBoundingClientRect();

    //Default values when content is to the right or left of button
    SvgW = Bwh + Space + ConW;
    SvgH = ConH;

    //If enough space above button, con mid is level with button mid
    if(bcl.top > ConH/2 - Bwh/2){
        ConY = - (ConH/2 - Bwh/2);
        SvgY = ConY;
    //else, con top is level with button top
    }else{
        ConY = 0;
        SvgY = ConY;
    }

    //If enough space to the right of the button
    if(winW - bcl.left > SvgW){
        ConX = Bwh + Space;
        SvgX = 0;
    //else, if enough space to the left of the button
    }else if(bcl.left > SvgW){
        ConX = - (ConW + 9);
        SvgX = ConX;

    //Else, place content below button, centred in screen
    }else{

        //Content is centred horizontally in screen
        ConX = - (bcl.left - Math.round((winW - ConW)/2));
        ConY = Bwh + Space;
        SvgY = 0;
        SvgH = Bwh + Space + ConH;

        //=== Remember === conX is relative to button left edge.
        //If button left edge is to the left of content left edge.
        if(ConX >= 0){
            SvgX = 0;
            SvgW = ConX + ConW;
        //If button right edge is to the right of content right edge.
        }else if(-ConX > ConW - Bwh){
            SvgX = ConX;
            SvgW = -ConX + Bwh;
        //If button is between left and right side of con
        }else{
            SvgX = ConX;
            SvgW = ConW;
        }
    }

    SVG.style.left = SvgX + "px";
    SVG.style.top = SvgY + "px";
    SVG.style.width =  SvgW + "px";
    SVG.style.height = SvgH + "px";

    //d-attribute value for bubble path
    let bubStr = drawBub(ConX,ConY + ConH/2,ConW,ConH,1.6);

    Show.setAttributeNS(null,"to", bubStr);

    Hide.setAttributeNS(null,"from", bubStr);

    SVG.setAttribute("viewBox", SvgX + " " + SvgY + " " + SvgW + " " + SvgH);

    Show.beginElement();
}

Show.addEventListener("endEvent", () => {  
    Con.style.left = ConX + "px";
    Con.style.top = ConY + "px";
    Con.style.visibility="visible";
    document.body.addEventListener("keydown",keyClose);
    document.body.addEventListener("click",mouseClose);
    Moving = false;
});

//================================================
const hideTip = function(){
    if(Moving){return}
    Moving = true;
    Btt.setAttribute("aria-expanded","false");
    //If the focus is on the bubble, place focus on the trigger button when bubble closes
    if(Con.contains(document.activeElement)){
        Btt.focus();
    }
    Con.style.visibility = "hidden";
    Hide.beginElement();
}

Hide.addEventListener("endEvent", () => {  
    SVG.style.left = "0px";
    SVG.style.top = "0px";
    SVG.style.width = "18px";
    SVG.style.height = "18px";
    SVG.setAttribute("viewBox", "0 0 18 18");
    document.body.removeEventListener("keydown",keyClose);
    document.body.removeEventListener("click",mouseClose);
    NoMouse = false;
    Moving = false;
});

//==========================================================
function drawBub(x,y,w,h,r){

    d = 39;

    //8 points. Each triplet is: controlpoint, controlpoint, endpoint
    var p = [[x,y - (h/2 - d)], [0,-d],[0,-d],[d,-d], [0,0],[w-2*d,0],[w-2*d,0], [d,0],[d,0],[d,d], [0,0],[0,h-2*d],[0,h-2*d], [0,d],[0,d],[-d,d], [0,0],[-(w-2*d),0],[-(w-2*d),0], [-d,0],[-d,0],[-d,-d], [0,0],[0,-(h-2*d)],[0,-(h-2*d)]];

    let ss = "M" + p[0][0] + "," + p[0][1] + " c";
    for(let i=1;i<p.length;i++){
        ss += p[i][0] + "," + p[i][1] + " ";
    }

    return ss;
}

const around = function(num,decimals){//CodeParrot
    if(!decimals) decimals = 1;
    const factor = Math.pow(10, decimals);
    return Math.round((num + Number.EPSILON) * factor) / factor;
}

}
Markup and code example, no animation

HTML

<p>
    Example:
    <span class="infotip2">
        <!-- backdrop expands when bubble is displayed, so mouseleave is not triggered when moving cursor from trigger to bubble -->
        <span class="back"></span>
        <button aria-label="Infotip" aria-expanded="false">
            <svg>
                <circle class="circle" cx="9" cy="9" r="9"></circle>
                <path class="mark" d="M 5.716,6.203 Q 7.054,3.527 9.486,3.77 11.432,4.014 11.919,5.838 12.284,7.419 10.338,8.514 9,9.243 9,11.189 "></path>
                <circle class="dot" cx="9" cy="14.108" r="1.5"></circle>
            </svg>
        </button>
        <span class="con" style="width: 160px; left: 27px; top: -42px; visibility: hidden;">
            An
            <a href="https://en.wiktionary.org/wiki/infotip" target="_blank">
                infotip
            </a>
            consists of a trigger and a floating text bubble, this is the bubble.
        </span>
    </span>
</p>

CSS

span.infotip2{
    position:relative;
    top:3px;
    display:inline-flex;
    padding:0;
    margin:0;
    width:18px;
    height:18px;

    span.back{
        position:absolute;
        left:0;
        bottom:0;
        cursor:auto;
    }
    > button{
        position:absolute;
        padding:0;
        margin:0;
        width:18px;
        height:18px;
        border:0;
        border-radius:9px;
        outline-offset:2px;

        svg{
            width:18px;
            height:18px;

            circle.circle {
                fill: #292929;
                cursor:pointer;
            }
            path.mark {
                stroke:#FFFFFF;
                stroke-width:2.6;
                fill:none;
                cursor:pointer;
            }
            circle.dot{
                fill:#FFFFFF;
                cursor:pointer;
            }
        }
    }

    span.con{
        z-index:99;
        position:absolute;
        display:block;
        width:fit-content;
        height:fit-content;
        max-height:fit-content;
        padding:9px 4px 9px 11px;
        font-size:13px;
        font-family:Verdana;
        color:#FFFFFF;
        background:#292929;
        border-radius:9px;

        a {
            color:#FFFFFF;
        }
    }
}

JavaScript

{

const It = document.querySelector("span.infotip2");

const Btt = It.querySelector("button");

const Con = It.querySelector("span.con");

const Back = It.querySelector("span.back");

const Bub = Back.querySelector("path.bubble");

//Content <span>. The width is fixed
let ConX;
let ConY;
const ConW = 160;
let ConH;

Con.style.width = ConW + "px";

//Backdrop
let BackX;
let BackY;
let BackW;
let BackH;

//Space between button and content/bubble
const Space = 9;
//Button width & height
const Bwh = 18;

//Prevent mouse hover functionality when display of help tip is by clicking the button
let NoMouse = false;

//Stats (show/hide) is changed by: button-click, mouseenter, mouseleave, Escape-key, and page-click
//Each event handler checks the state of aria-expanded so showTip() and hideTip() do not need to check this,
//showTip() and hideTip() just set the state of aria-expanded
Btt.addEventListener("click",()=>{
    if(Btt.getAttribute("aria-expanded") === "false"){
        showTip(true);
    }else{
        hideTip();
    }
});

It.addEventListener("mouseenter",()=>{
//Delay show on mousehover a little bit so it doesn't pop up when user is just moving cursor across screen
    setTimeout(function(){
        if(NoMouse){return}
        //After delay, only show if cursor is still over trigger
        if([...It.parentNode.querySelectorAll(":hover")].includes(It)){
            if(Btt.getAttribute("aria-expanded") === "false"){
                showTip();
            }
        }
    },50);
});

It.addEventListener("mouseleave",()=>{
    if(NoMouse){return}
    if(Btt.getAttribute("aria-expanded") === "true"){
        hideTip();
    }
});

//The following two functions are only active when the tip is open, so no need to check the value of 'aria-expanded'
const keyClose = function(ev){
    if(ev.key === "Escape"){
        hideTip();
    }
}

const mouseClose = function(ev){
    //Ignore any click on the help tip
    //except if the click is on the SVG between button and bubble.
    if(It.contains(ev.target) && ev.target !== Back){
        return;
    }
    hideTip();
}

//================================================
const showTip = function(SetNoMouse){

    if(SetNoMouse){
        NoMouse = true;
    }
    Btt.setAttribute("aria-expanded","true");

    ConH = Con.offsetHeight;

    const winW = window.innerWidth;

    const bcl = It.getBoundingClientRect();

    //Default values when content is to the right or left of button
    BackW = Bwh + Space + ConW;
    BackH = ConH;

    //If enough space above button, con mid is level with button mid
    if(bcl.top > ConH/2 - Bwh/2){
        ConY = - (ConH/2 - Bwh/2);
        BackY = ConY;
    //else, con top is level with button top
    }else{
        ConY = 0;
        BackY = ConY;
    }

    //If enough space to the right of the button
    if(winW - bcl.left > BackW){
        ConX = Bwh + Space;
        BackX = 0;
    //else, if enough space to the left of the button
    }else if(bcl.left > BackW){
        ConX = - (ConW + 9);
        BackX = ConX;

    //Else, place content below button, centred in screen
    }else{

        //Content is centred horizontally in screen
        ConX = - (bcl.left - Math.round((winW - ConW)/2));
        ConY = Bwh + Space;
        BackY = 0;
        BackH = Bwh + Space + ConH;

        //=== Remember === conX is relative to button left edge.
        //If button left edge is to the left of content left edge.
        if(ConX >= 0){
            BackX = 0;
            BackW = ConX + ConW;
        //If button right edge is to the right of content right edge.
        }else if(-ConX > ConW - Bwh){
            BackX = ConX;
            BackW = -ConX + Bwh;
        //If button is between left and right side of con
        }else{
            BackX = ConX;
            BackW = ConW;
        }
    }

    Back.style.left = BackX + "px";
    Back.style.top = BackY + "px";
    Back.style.width =  BackW + "px";
    Back.style.height = BackH + "px";

    Con.style.left = ConX + "px";
    Con.style.top = ConY + "px";
    Con.style.visibility="visible";

    document.body.addEventListener("keydown",keyClose);
    document.body.addEventListener("click",mouseClose);
}

//================================================
const hideTip = function(){
    Btt.setAttribute("aria-expanded","false");
    //If the focus is on the bubble, place focus on the trigger button when bubble closes
    if(Con.contains(document.activeElement)){
        Btt.focus();
    }
    Con.style.visibility = "hidden";

    Back.style.left = "0px";
    Back.style.top = "0px";
    Back.style.width = "18px";
    Back.style.height = "18px";

    document.body.removeEventListener("keydown",keyClose);
    document.body.removeEventListener("click",mouseClose);
    NoMouse = false;
}

}

Usability considerations

Button trigger infotip, with close button

This infotip is implemented as above, with the addition of a close button.

The button is placed at the end of the bubble content in the DOM sequence and is therefore read last by screen readers. This helps screen reader users identify where the infotip ends. The label for the button is "Close infotip".

Example:

Button trigger infotip, no mouse hover

This infotip is implemented as above, except it is not displayed on mouse hover.

Example: An infotip consists of a trigger and a floating text bubble, this is the bubble.

Image trigger infotip

This implementation can be useful when retrofitting accessibility to existing infotips; however, it dplays the infotip on keyboard focus and this can be distracting to keyboard-only users who simply navigate through the content by pressing the Tab key. The button trigger infotip above is a more user friendly option.

For this infotip, the trigger is implemented as an image that is focusable via the tabindex="0" attribute. The infotip is displayed on keyboard focus and on mouse hover.

The alt-text for the image contains the infotip text with the addition of the phrase "infotip:". This communicates to screen reader users that the content is an infotip. In addition, it explains to screen reader users why the image is keyboard focusable (images are not usually focusable).

In this implementation, two "versions" of the infotip content exists, the:

As the infotip content is provided to screen readers via an attribute value (text-only content), the:

Example:

Markup and code example

HTML

<p>
    Example:
    <span class="infotip5">
        <svg role="img" aria-label="Infotip: An infotip consists of a trigger and a floating text bubble, this is the bubble." tabindex="0">
            <circle class="circle" cx="9" cy="9" r="9"></circle>
            <path class="mark" d="M 5.716,6.203 Q 7.054,3.527 9.486,3.77 11.432,4.014 11.919,5.838 12.284,7.419 10.338,8.514 9,9.243 9,11.189 "></path>
            <circle class="dot" cx="9" cy="14.108" r="1.5"></circle>
        </svg>
    </span>
</p>

CSS

span.infotip5 {
    position:relative;
    top:3px;
    display:inline-flex;
    padding:0;
    margin:0;
    width:18px;
    height:18px;

    svg{
        width:18px;
        height:18px;
        border-radius:9px;
        outline-offset: 2px;

        path.bubble {
            fill: #292929;
        }
        circle.circle {
            fill: #292929;
            cursor:pointer;
        }
        path.mark {
            stroke:#FFFFFF;
            stroke-width:2.6;
            fill:none;
            cursor:pointer;
        }
        circle.dot{
            fill:#FFFFFF;
            cursor:pointer;
        }
    }
}

body > span > svg.back{
    z-index:99;
    position:fixed;
    left:0;
    bottom:0;
    cursor:auto;
}

body > span > svg.back > path.bubble {
    fill: #292929;
}

body > span > span.con{
    z-index:100;
    position:fixed;
    display:block;
    height:fit-content;
    padding:9px 4px 9px 11px;
    font-size:13px;
    font-family:Verdana;
    color:#FFF;

    a {
        color:white;
    }
}

JavaScript

{

const It = document.querySelector("span.infotip5");
const Qmark = It.querySelector('svg[role="img"]');

let SVG;
let Show;
let Hide;
let Con;

//Content <span>. The width is fixed
let ConX;
let ConY;
const ConW = 160;
let ConH;

//SVG
let SvgX;
let SvgY;
let SvgW;
let SvgH;

//Space between button and content/bubble
const Space = 9;
//Button width & height
const Bwh = 18;

//Prevent trigger of expand/collapse mid-animation
let Moving = false;
let Expanded = false;
let ClosedByEscape = false;

let ClientX;
let ClientY;

//===========================================================
It.addEventListener("mouseenter",()=>{
    //Delay show on mousehover a little bit so it doesn't pop up when user is just moving cursor across screen
    setTimeout(function(){
        //After delay, only show if cursor is still over trigger
        if([...It.parentNode.querySelectorAll(":hover")].includes(It)){
            if(ClosedByEscape){
                ClosedByEscape = false;
                return;
            }
            showTip();
        }
    },50);
});

Qmark.addEventListener("focus",()=>{
    showTip();
});

Qmark.addEventListener("blur",()=>{
    hideTip();
});

//====
const keyClose = function(ev){
    if(ev.key === "Escape"){
        //Set ClosecByEscape set to true if mouse cursor is above trigger. This means the mouseenter that
        //is triggered when bubble closes is ignored and we avoid bubble opening up again.
        let bcl = It.getBoundingClientRect();
        if(ClientX > bcl.left && ClientX < bcl.right && ClientY > bcl.top && ClientY < bcl.bottom){
            ClosedByEscape = true;
        }
        hideTip();
    }
}

const mouseClose = function(ev){
    //Ignore any click on the help tip,
    //except if the click is on the SVG between button and bubble.
    if(Span.contains(ev.target) && ev.target !== SVG){
        return;
    }
    //Ignore if the click is on the trigger. If clicking on trigger closes the infotip, it
    //will immediately open again as mouseenter is triggered.
    let bcl = It.getBoundingClientRect();
    if(ClientX > bcl.left && ClientX < bcl.right && ClientY > bcl.top && ClientY < bcl.bottom){
        return;
    }
    hideTip();
}

//Called on mousemove
const mouseTrack = function(ev){
    ClientX = ev.clientX;
    ClientY = ev.clientY;
}

//Called on scroll
const adjustPosition = function(){
    const bcl = It.getBoundingClientRect();
    SVG.style.left = bcl.left + SvgX + "px";
    SVG.style.top = bcl.top + SvgY + "px";
    Con.style.left = bcl.left + ConX + "px";
    Con.style.top = bcl.top + ConY + "px";
}

//================================================
//================================================
const showTip = function(){

    if(Moving){return}
    if(Expanded){return}

    Moving = true;

    const ns = "http://www.w3.org/2000/svg";

    Span = document.createElement("span");

    SVG = document.createElementNS(ns,"svg");
    SVG.setAttribute("class","back");
    SVG.setAttribute("viewBox","0 0 18 18");

    let path = document.createElementNS(ns,"path");
    path.setAttribute("class","bubble");
    SVG.appendChild(path);

    Show = document.createElementNS(ns,"animate");
    Show.setAttribute("class","show");
    Show.setAttribute("attributeName","d");
    Show.setAttribute("begin","indefinite");
    Show.setAttribute("dur",".18");
    Show.setAttribute("repeatCount","1");
    Show.setAttribute("fill","freeze");
    path.appendChild(Show);

    Hide = document.createElementNS(ns,"animate");
    Hide.setAttribute("class","hide");
    Hide.setAttribute("attributeName","d");
    Hide.setAttribute("begin","indefinite");
    Hide.setAttribute("dur",".18");
    Hide.setAttribute("repeatCount","1");
    Hide.setAttribute("fill","freeze");
    path.appendChild(Hide);
    Span.appendChild(SVG);

    Con = document.createElement("span");
    Con.setAttribute("class","con");
    Con.style.visibility = "hidden";
    Con.style.width = ConW + "px";
    let s = Qmark.getAttribute("aria-label");
    if(s.indexOf("Infotip: ") === 0 ){
        s = s.slice(9,);
    }
    Con.textContent = s;
    Span.appendChild(Con);

    Span.addEventListener("mouseleave",()=>{
        hideTip();
    });

    document.body.appendChild(Span);

    ConH = Con.offsetHeight;

    const winW = window.innerWidth;

    const bcl = It.getBoundingClientRect();

    //Default values when content is to the right or left of button
    SvgW = Bwh + Space + ConW;
    SvgH = ConH;

    //If enough space above button, con mid is level with button mid
    if(bcl.top > ConH/2 - Bwh/2){
        ConY = - (ConH/2 - Bwh/2);
        SvgY = ConY;
    //else, con top is level with button top
    }else{
        ConY = 0;
        SvgY = ConY;
    }

    //If enough space to the right of the button
    if(winW - bcl.left > SvgW){
        ConX = Bwh + Space;
        SvgX = 0;
    //else, if enough space to the left of the button
    }else if(bcl.left > SvgW){
        ConX = - (ConW + 9);
        SvgX = ConX;

    //Else, place content below button, centered in screen
    }else{

        //Content is centered horizontally in screen
        ConX = - (bcl.left - Math.round((winW - ConW)/2));
        ConY = Bwh + Space;
        SvgY = 0;
        SvgH = Bwh + Space + ConH;

        //=== Remember === conX is relative to button left edge.
        //If button left edge is to the left of content left edge.
        if(ConX >= 0){
            SvgX = 0;
            SvgW = ConX + ConW;
        //If button right edge is to the right of content right edge.
        }else if(-ConX > ConW - Bwh){
            SvgX = ConX;
            SvgW = -ConX + Bwh;
        //If button is between left and right side of con
        }else{
            SvgX = ConX;
            SvgW = ConW;
        }
    }

    SVG.style.left = bcl.left + SvgX + "px";
    SVG.style.top = bcl.top + SvgY + "px";
    SVG.style.width =  SvgW + "px";
    SVG.style.height = SvgH + "px";

    SVG.setAttribute("viewBox", SvgX + " " + SvgY + " " + SvgW + " " + SvgH);

    //Start size is a little bubble in the center
    let dStart = drawBub(ConX + ConW/2 - 2,ConY + ConH/2 - 2,2,2,1);
    path.setAttribute("d", dStart);
    Show.setAttribute("from", dStart);
    Hide.setAttribute("to", dStart);

    //End size is big bubble to contain the textt
    let dEnd = drawBub(ConX,ConY + ConH/2,ConW,ConH);
    Show.setAttribute("to", dEnd);
    Hide.setAttribute("from", dEnd);
    //===========================================

    Show.addEventListener("endEvent", () => {  
        Con.style.left = bcl.left + ConX + "px";
        Con.style.top = bcl.top + ConY + "px";
        Con.style.visibility="visible";
        document.body.addEventListener("keydown",keyClose);
        document.body.addEventListener("click",mouseClose);
        document.body.addEventListener("mousemove",mouseTrack);
        window.addEventListener("scroll",adjustPosition);

        Expanded = true;
        Moving = false;
    });

    Hide.addEventListener("endEvent", () => {
        Span.remove();
        document.body.removeEventListener("keydown",keyClose);
        document.body.removeEventListener("click",mouseClose);
        document.body.addEventListener("mousemove",mouseTrack);
        window.removeEventListener("scroll",adjustPosition);
        //Get all, just in case..
        document.querySelectorAll("body > svg.back").forEach(el => el.remove());
        document.querySelectorAll("body > span.con").forEach(el => el.remove());
        Expanded = false;
        Moving = false;
    });

    Show.beginElement();
}

//================================================
//================================================
const hideTip = function(){
    if(Moving){return}
    if(!Expanded){return}
    Moving = true;
    Con.style.visibility = "hidden";
    Hide.beginElement();
}

//==========================================================
function drawBub(x,y,w,h,d){

    if(!d){d = 39}

    var h2 = around(h/2);

    //8 points. Each triplet is: controlpoint, controlpoint, endpoint
    var p = [[x,y - (h2 - d)], [0,-d],[0,-d],[d,-d], [0,0],[w-2*d,0],[w-2*d,0], [d,0],[d,0],[d,d], [0,0],[0,h-2*d],[0,h-2*d], [0,d],[0,d],[-d,d], [0,0],[-(w-2*d),0],[-(w-2*d),0], [-d,0],[-d,0],[-d,-d], [0,0],[0,-(h-2*d)],[0,-(h-2*d)]];

    let ss = "M" + p[0][0] + "," + p[0][1] + " c";
    for(let i=1;i<p.length;i++){
        ss += p[i][0] + "," + p[i][1] + " ";
    }

    return ss;
}

const around = function(num,decimals){//CodeParrot
    if(!decimals) decimals = 1;
    const factor = Math.pow(10, decimals);
    return Math.round((num + Number.EPSILON) * factor) / factor;
}

}

Button trigger infotip via popover

The infotips above are implemented via JavaScript (and SVG animations); however, accessible infotips can also be implemented via the Popover API , with minimal coding.

On desktop, a popover infotip provides the same level of keyboard and screen reader access regardless of the bubble position in the DOM sequence (e.g. straight after the trigger button or at the bottom of the page); however, this is not supported on mobile devices, and therefore, the infotip must be inserted in the DOM sequence immediately after the trigger.

Here is an example using popover:

Markup and code example

HTML

<p>
    Here is an example using popover:
    <button class="infotip6" aria-label="Infotip" popovertarget="bubble6">
        <svg>
            <circle class="circle" cx="9" cy="9" r="9"></circle>
            <path class="mark" d="M 5.716,6.203 Q 7.054,3.527 9.486,3.77 11.432,4.014 11.919,5.838 12.284,7.419 10.338,8.514 9,9.243 9,11.189 "></path>
            <circle class="dot" cx="9" cy="14.108" r="1.5"></circle>
        </svg>
    </button>
    <span id="bubble6" popover="" style="visibility: hidden">
        An
        <a href="https://en.wiktionary.org/wiki/infotip" target="_blank">
            infotip
        </a>
        consists of a trigger and a floating text bubble, this is the bubble.
    </span>
</p>

CSS

button.infotip6{
    position:relative;
    top:2px;
    padding:0;
    margin:0;
    width:18px;
    height:18px;
    border:0;
    border-radius:9px;
    outline-offset:2px;

    svg{
        width:18px;
        height:18px;

        circle.circle {
            fill: #292929;
            cursor:pointer;
        }
        path.mark {
            stroke:#FFFFFF;
            stroke-width:2.6;
            fill:none;
            cursor:pointer;
        }
        circle.dot{
            fill:#FFFFFF;
            cursor:pointer;
        }
    }
}

#bubble6 {
    width:166px;
    height:fit-content;
    padding:9px 4px 9px 11px;
    font-size:13px;
    font-family:Verdana;
    color:#FFF;
    background:#292929;
    border-radius:19px;

    a {
        color:white;
    }
}

#bubble6:popover-open {
    /*Default is "auto" which positions it in the middle.*/
    margin:0;
    border:0;
}

JavaScript

{

bubble6.addEventListener("toggle", (ev) => {
    if(ev.newState === "open"){
        positionPopover();
        //Turning the visibility on and off is not strictlhy needed, it's only added to
        //avoid flicker when the popover is displayed
        bubble6.style.visibility = "visible";
        window.addEventListener("scroll",positionPopover);
    }else{
        window.removeEventListener("scroll",positionPopover);
        bubble6.style.visibility = "hidden";
    }
});

//Space between button and content/bubble
const Space = 9;
//Button width & height
const Bwh = 18;

const Btt = document.querySelector("button.infotip6");

const positionPopover = function(){

    const conW = bubble6.offsetWidth;

    const conH = bubble6.offsetHeight;

    const winW = window.innerWidth;

    const bcl = Btt.getBoundingClientRect();

    //Bubble coordinates
    let x;
    let y;

    //If enough space above button, bubble middel is level with button mid
    if(bcl.top > conH/2 - Bwh/2){
        y = bcl.top + Bwh/2 - conH/2;
    //else, bubble top is level with button top
    }else{
        y = bcl.top;
    }

    //If enough space to the right of the button
    if(winW - bcl.right - Space > conW){
        x = bcl.right + Space;
    //else, if enough space to the left of the button
    }else if(bcl.left > conW + Space){
        x = bcl.left - Space - conW;

    //Else, place content below button, centered in screen
    }else{

        x = winW/2 - conW/2;
        y = bcl.bottom + Space;
    }

    bubble6.style.left = x + "px";
    bubble6.style.top = y + "px";
}

}

By default, the bubble is positioned in the centre of the screen. The API includes a CSS anchor functionality that can position the bubble close to the trigger; however, this is not fully supported by browsers, so the positioning for this example is managed via scripting.

Terms of Use

This software is being provided "as is" - without any express or implied warranty. In particular, me2 Accessibility does not make any representation or warranty of any kind concerning the reliability, quality, or merchantability of this software or its fitness for any particular purpose. In addition, me2 Accessibility does not guarantee that use of this tool will ensure the accessibility of your web content or that your web content will comply with any specific accessibility standard.

Creative commons licence - logo
This work is licensed under a Creative Commons License

Contact Us

Need a quote / Have a query?

Please get in touch

Contact Us
Level AA conformance, W3C WAI Web Content Accessibility Guidelines 2.2 Certified WCAG level AA. me2Accessibility logo