Skip to main content

A material design inspired show & hide.
Very accessible, vanilla JavaScript tasty goodness no?

- (update: )

Accessibly show & hide blocks of content.

Failed testing.

The heading lost its properties when role="button" was added. Rebuild required.

Please use peek-a-boo v6 until resolved.

Fully annotated script is available in the demo source or take a look at the Codepen.

Most of the hidden sections on this site are either lists of links or technical content code blocks and make use of the technique described here.

Note this is not strictly an accordion (one drawer open at a time), though modification should be possible.

In version 7 class names are depricated in favour of ARIA attributes wherever possible. Which enforces correct usage.

Mark-up

The actual tags used are strongly recommended for the semantic value, but are not enforced. You may freely change any of them. For example; the container to a div, the heading to a paragraph, the div to an unordered list. For the sake of simplicity we'll refer to them as container, heading, and block.

Mark a heading with data-pab, to act as a JavaScript hook, and set its value to the block id name. In this example "pab-content".

Language HTML
<section>

  <h2 data-pab="pab-content">Title text</h2>

  <div id="pab-content">
    <p>The hidden content.</p>
    …
  </div>

</section>

The script builds on the core markup retaining the key semantic structure.

The JavaScipt changes the heading behavior to that of an activating button.

ARIA roles are added to notify AT of the expected behavior change, and an SVG icon is added as a visual clue.

Language HTML
<section class="pab_container">
  <h2 aria-controls=pab-content
      id=pab_0
      aria-expanded=false
      tabindex=0
      role=button
      data-pab=pab-content>
    <svg class=svg-plus width=38 height=38 viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg">
      <title>Show</title>
      <path d="M10.5 19l17 0"/>
      <path d="M19 10.5l0 17"/>
    </svg>
    Title text
    <span id=spt_1 class=spot></span>
  </h2>
  <div aria-labelledby=pab_0
      aria-hidden=true
      id=pab-content>
    <p>The hidden content.</p>
    …
  </div>
</section>

Optionally predefine the container class "pab-container" to allow the CSS to run on-load rather than after the JavaScript has kicked in. It's always about performance.

Adding aria-expanded="true" to the heading will force the hidden section to be open by default.

Once initialised the script changes the states of the ARIA attributes which are actioned by CSS. No class names were hurt in the making of this module.

CSS

CSS is added at different support levels. Firstly before JavaScript, that's if class="pab_container" is in HTML.

Language CSS
.pab_container {
  margin: 1.618rem 0;
  border: 1px solid rgba(96,96,128,.1);
  position: relative;
  background-color: rgba(255,255,255,.3);
  transition:
    background-color .3s ease-out,
    box-shadow .3s ease-out;
}

The code is agnostic to what HTML elements are used but they must be in the set order: container > heading + block.

Language CSS
.pab_container > :first-child {
  text-align: left;
  font-size: 1.118rem;
  padding: 0.618rem .236rem .618rem 2.618rem;
  background-color: transparent;
  border: 1px solid rgba(255,255,255,.7);
}
.pab_container > :nth-child(2),
.pab_container > :nth-child(3) {
  /* :nth-child(3) added so padding is
      applied to the clone when
      calculating max-height */
  padding: 0 1rem;
  list-style: none; /* if ul or ol */
}

The rest of the CSS is only actioned if JavaScript has modified the HTML attributes.

The heading becomes the toggle button.

Language CSS
[data-pab][role=button] {
  position: relative;
  cursor: pointer;
  overflow: hidden;
  touch-action: manipulation; /* ??? */
  transition:
    box-shadow 0.2s ease-in,
    background-color 0.2s ease-in,
    color 0.3s ease-in;

  /* reserve space for SVG absolute positioned */
  padding-left: 2.618rem;
}
[data-pab][role=button]:hover,
[data-pab][role=button]:focus,
[data-pab][role=button]:active {
  color: #236ECE;
  background-color: #fff;
}
[data-pab][role=button]:focus {
  outline: 0 solid;
}
[data-pab][aria-expanded=true] {
  box-shadow: 0 1px 0 rgba(0,0,0,.1);
}
[data-pab] * {
  pointer-events: none;
}

The container has CSS added / removed on interactions. Used to highlight the whole container when the heading (button) is hovered or focused.

Language CSS
.pab_btn_hovered,
.pab_btn_focused {
  background-color: rgba(255, 255, 255, 0.65);
  box-shadow: 0 4px 4px rgba(0,0,0,0.3);
}

The animated SVG plus / minus icon

Language CSS
.svg-plus {
  display: block;
  position: absolute;
  top: calc(50% - 1rem);
  left: 0.618rem;
  width: 1.8rem;
  height: 1.8rem;
  margin: 0;
  transition: transform .7s ease-out;
  pointer-events: none;
}
.svg-plus > path {
  stroke-width: 5;
  stroke-linecap: square;
  stroke: #9F9DB2;
  transition:
    stroke 0.5s ease-out,
    opacity 0.7s ease-out;
}
[aria-expanded=true] > .svg-plus {
  transform: rotateZ(360deg);
}
[aria-expanded=true] > .svg-plus > :last-child {
  opacity: 0;
}
[data-pab]:hover > .svg-plus > path,
[data-pab]:focus > .svg-plus > path {stroke: #418cec}

You may of noticed a span with class="spot" was also added by the JavaScript. This provides the click or tap feedback animation. A nice touch to my mind.

Language CSS
.spot {
  display: block;
  position: absolute;
  background: rgba(35,110,206,0.35);
  border-radius: 50%;
  transform: scale(0);
  opacity: 1;
  -webkit-filter: blur(1rem);
  filter: blur(1rem);
}
[aria-expanded=true] .spot {animation: spot-open 0.6s ease-in;}
[aria-expanded=false] .spot {animation: spot-close 0.6s ease-out;}

@keyframes spot-open {
  to {
    opacity: 0;
    transform: scale(2.4);
  }
}
@keyframes spot-close {
 0% {
    opacity: 0;
    transform: scale(2.4);
  }
  100% {
    opacity: 1;
    transform: scale(0);
  }
}

The aria-hidden attribute controls the "hidden" block animation.

Caveat: Avoid vertical margins, or padding, applied directly to the block.

Language CSS
/*  Just the open / close animation
    Problem is the inaccurate max-height
    Which is resolved via JS */
[data-pab] + [aria-hidden] {
  overflow: hidden;
  opacity: 1;
  max-height: 10rem;
  visibility: visible;
  transition:
    visibility 0s ease 0s,
    max-height .65s ease-out 0s,
    opacity .65s ease-in 0s;
}
[data-pab] + [aria-hidden=true] {
  max-height: 0;
  opacity: 0;
  visibility: hidden;
  transition-delay: 1s, 0s, 0s;
}
/* Overide max-height set as an inline style by the JS */
[data-pab] + [style][aria-hidden=true] {
  max-height: 0 !important;
}

Hardware acceleration for the transitions, not needed but never hurts.

Language CSS
.spot,
[data-pab] + [aria-hidden] {
   transform: translate3d(0, 0, 0);
   backface-visibility: hidden;
   perspective: 1000;
}

Google closure compiled

v7.0 - 1.26KB gzipped (3.19KB uncompressed).

Language CSS
var Pab=function(r,k,t){function e(b){return b&&("true"===b.getAttribute("aria-expanded")||"false"===b.getAttribute("aria-hidden"))}var g="data-pab".replace("data-","")+"_",h=0,l=function(b){var a=b.cloneNode(!0),c=0;a.setAttribute("style","display:block;width:"+b.clientWidth+"px;position:absolute;top:0;left:-999rem;max-height:none;height:auto;visibility:hidden;");b.parentElement.appendChild(a);c=a.clientHeight;b.parentElement.removeChild(a);return c},n=function(b,a,c){b.setAttribute("aria-expanded",
!c);e(a)||(a.style.maxHeight=l(a)+"px");a.setAttribute("aria-hidden",c);m(b)},f=function(b){var a=g+"btn_";switch(b.type){case "focus":b.target.parentNode.classList.add(a+"focused");break;case "blur":b.target.parentNode.classList.remove(a+"focused");break;case "mouseover":b.target.parentNode.classList.add(a+"hovered");break;case "mouseout":b.target.parentNode.classList.remove(a+"hovered")}},u=function(b,a){var c=a.clientWidth,d=b.offsetX-c/2,e=b.offsetY-c/2,f=document.getElementById(a.spotId);"keydown"===
b.type&&(d=0,e=-(c-a.clientHeight)/2);window.requestAnimationFrame(function(){f.setAttribute("style","top:"+e+"px;left:"+d+"px;height:"+c+"px;width:"+c+"px")})},m=function(b){var a=b.getElementsByTagName("title");a&&a[0]&&(a[0].innerHTML=e(b)?"Hide":"Show")},p=function(b){var a=b.target,c,d;a&&(c=document.getElementById(a.getAttribute("aria-controls")))&&(b.preventDefault(),d=e(a),n(a,c,d),u(b,a),b=a.id,c=d?!d:c.id,k&&(c?localStorage["target_"+b]=c:localStorage.removeItem("target_"+b)))},v=function(b){13!==
b.which&&32!==b.which||p(b)},w=function(b){var a=b.cloneNode(!0);a.innerHTML.match("svg")||(a.innerHTML=r+a.innerHTML,m(a),requestAnimationFrame(function(){b.parentElement.replaceChild(a,b)}));return a},x=function(b){var a=b.cloneNode(!0);a.innerHTML.match('id="spt_')||(a.innerHTML+="<span id=spt_"+h+" class=spot></span>",a.spotId="spt_"+h,requestAnimationFrame(function(){b.parentElement&&b.parentElement.replaceChild(a,b)}));return a},q=function(){Array.prototype.slice.call(document.querySelectorAll("[data-pab]")).reduce(function(b,
a){var c=a.getAttribute("data-pab");if(c=document.getElementById(c)){var d=a;d.setAttribute("role","button");d.setAttribute("tabindex",0);d.hasAttribute("aria-expanded")||d.setAttribute("aria-expanded",!1);d.id||d.setAttribute("id",g+h++);d.setAttribute("aria-controls",d.getAttribute("data-pab"));(d=a.parentElement)&&d.classList.add(g+"container");a=w(a);d=a=x(a);c.setAttribute("aria-hidden",!e(d));c.setAttribute("aria-labelledby",d.id);c=a;c.addEventListener("focus",f,!1);c.addEventListener("blur",
f,!1);c.addEventListener("mouseout",f,!1);c.addEventListener("mouseover",f,!1);c.addEventListener("click",p,!1);c.addEventListener("keydown",v,!1)}return!0},{});return!0};q();(function(){k&&setTimeout(function(){for(var b,a,c=localStorage.length;c--;)b=localStorage.key(c),b.match("target_")&&(a=localStorage.getItem(b),b=document.getElementById(b.replace("target_","")),(a=document.getElementById(a))&&b&&n(b,a,!1))},1E3)})();window.addEventListener("resize",t(function(){for(var b=document.querySelectorAll("[data-pab]"),
a=b.length,c;a--;)if(c=document.getElementById(b[a].getAttribute("data-pab")))c.style.maxHeight=l(c)+"px"},500));return{addToggles:q}}(svg_plus,useLocalStorage,debounce);

// Dynamically check for new toggles and add.
// Pab.addToggles();

Still to do

Change from using on-click to on-touch-end to improve responsiveness on touch devices.

Work this version back into the site content. Though I believe this is the point where NPM comes in. Time to upscale production techniques…

Socialise: