2026-05-15 09:20:43 -07:00
<!doctype html>
< html lang = "en" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< link rel = "icon" type = "image/x-icon" href = "/favicon.ico" >
< title > accessible image modals | hello hello< / title >
< meta name = "description" content = "Lee Cattarin... on the internet!" >
< link rel = "alternate" href = "/feed.xml" type = "application/atom+xml" title = "hello hello" >
< meta property = "og:title" content = "accessible image modals | hello hello" >
< meta property = "og:type" content = "website" >
< meta property = "og:description" content = "Lee Cattarin... on the internet!" >
< meta property = "og:site_name" content = "hello hello" >
< meta property = "og:image" content = "/img/snacking-seagull.jpg" >
< meta property = "og:image:alt" content = "Image unrelated to post. A seagull floating in the water with a starfish hanging out of eir mouth." >
< meta name = "generator" content = "Eleventy v3.1.2" >
2026-05-21 23:35:46 +00:00
< meta itemprop = "ai-content-declaration:version" content = "1.0.0" >
< meta itemprop = "ai-content-declaration:level" content = "none" >
2026-05-15 09:20:43 -07:00
< link rel = "preconnect" href = "https://fonts.googleapis.com" >
< link rel = "preconnect" href = "https://fonts.gstatic.com" crossorigin = "" >
< link href = "https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:ital,wght@0,200..800;1,200..800&family=Atkinson+Hyperlegible+Next:ital,wght@0,200..800;1,200..800&display=swap" rel = "stylesheet" >
< script src = "https://kit.fontawesome.com/884dded219.js" crossorigin = "anonymous" > < / script >
< style > . p o s t - m e t a d a t a {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: baseline;
margin: 1.5rem 0 .5rem;
}
.post-metadata p {
font-size: .9rem;
margin: 0;
}
.post-tags {
display: flex;
flex-flow: row wrap;
gap: .5rem;
list-style: none;
margin: 0;
}
.post-tags li {
margin: 0;
}
.post-tags li a {
text-decoration: none;
color: var(--color-teal);
padding: 0 .5rem;
border-radius: 1rem;
box-shadow: .15rem .15rem var(--color-shadow);
border: .08rem solid var(--color-teal);
line-height: 2;
/* Click animation handling */
position: relative;
top: 0;
left: 0;
transition: top .1s ease-in, left .1s ease-in;
}
.post-tags li a:focus-visible {
outline: none;
background-color: var(--color-teal);
color: var(--color-bg);
}
@media (any-hover: hover) {
.post-tags li a:hover {
outline: none;
background-color: var(--color-teal);
color: var(--color-bg);
}
}
@media (forced-colors: active) {
.post-tags li a:focus-visible {
outline-offset: .08rem;
outline: .08rem solid;
}
@media (any-hover: hover) {
.post-tags li a:hover {
outline-offset: .08rem;
outline: .08rem solid;
}
}
}
/* Click animation */
.post-tags li a:active {
top: .1rem;
left: .1rem;
box-shadow: .05rem .05rem var(--color-shadow);
}
/* Adapted from PrismJS 1.30.0 Tomorrow Night theme
https://prismjs.com/download
*/
code,
pre,
code[class*=language-],
pre[class*=language-] {
font-family: var(--font-family-code);
background-color: var(--color-bg-alt);
font-size: .9em;
text-shadow: 0 1px var(--color-shadow);
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre,
pre[class*=language-] {
margin: 1rem 0;
padding: 1rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
border-radius: .5rem;
overflow: auto;
}
:not(pre)>code,
:not(pre)>code[class*=language-] {
padding: .2rem;
border-radius: .25rem;
white-space: normal;
}
/* Selected text */
code ::-moz-selection,
code::-moz-selection,
pre ::-moz-selection,
pre::-moz-selection,
code[class*=language-] ::-moz-selection,
code[class*=language-]::-moz-selection,
pre[class*=language-] ::-moz-selection,
pre[class*=language-]::-moz-selection,
code ::selection,
code::selection,
pre ::selection,
pre::selection,
code[class*=language-] ::selection,
code[class*=language-]::selection,
pre[class*=language-] ::selection,
pre[class*=language-]::selection {
text-shadow: none;
background-color: var(--color-bg);
}
/* Syntax highlighting */
.token.namespace {
opacity: .7;
}
.token.bold,
.token.important {
font-weight:700
}
.token.italic {
font-style:italic
}
.token.block-comment,
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog,
.token.punctuation {
color: var(--color-grey);
}
.token.attr-name,
.token.deleted,
.token.namespace,
.token.tag {
color: var(--color-red);
}
.token.boolean,
.token.function,
.token.number {
color: var(--color-orange);
}
.token.class-name,
.token.constant,
.token.property,
.token.symbol {
color: var(--color-yellow);
}
.token.attr-value,
.token.char,
.token.regex,
.token.string,
.token.variable,
.token.inserted {
color: var(--color-green);
}
.token.entity,
.token.operator,
.token.url,
.token.function-name {
color:var(--color-blue);
}
.token.atrule,
.token.builtin,
.token.important,
.token.keyword,
.token.selector {
color: var(--color-purple);
}
#postlist,
#taglist {
list-style: none;
}
#postlist, .post,
#taglist, .tag {
margin: 0;
}
/* Odd-numbered posts & tag layout/coloration */
.post:nth-child(odd) .postlink,
.tag:nth-child(odd) .taglink {
grid-template-areas:
'img h2'
'img info'
'img .';
grid-template-columns: 45% auto;;
--color-primary: var(--color-teal);
--color-accent: var(--color-pink);
}
/* Even-numbered posts & tags layout/coloration */
.post:nth-child(even) .postlink,
.tag:nth-child(even) .taglink {
grid-template-areas:
'h2 img'
'info img'
'. img';
grid-template-columns: auto 45%;
--color-primary: var(--color-pink);
--color-accent: var(--color-teal);
}
/* Layout for all posts on mobile */
@media (max-width: 650px) {
.post:nth-child(n) .postlink,
.tag:nth-child(n) .taglink {
grid-template-areas:
'img'
'h2'
'info';
grid-template-columns: auto;
}
}
/* Link */
.postlink,
.taglink {
display: grid;
border: .25rem solid var(--color-primary);
border-radius: 1.25rem;
box-shadow: .35rem .35rem var(--color-shadow);
margin: 2rem 0;
text-decoration: none;
/* Click animation handling */
position: relative;
top: 0;
left: 0;
transition: top .05s ease-in, left .05s ease-in;
}
.postlink:focus-visible,
.taglink:focus-visible {
background-color: var(--color-primary);
outline: none;
}
@media (any-hover: hover) {
.postlink:hover,
.taglink:hover {
background-color: var(--color-primary);
}
}
/* Forced colors */
@media (forced-colors: active) {
.postlink:focus-visible,
.taglink:focus-visible {
outline-offset: .25rem;
outline: .25rem solid;
}
@media (any-hover: hover) {
.postlink:hover,
.taglink:hover {
outline-offset: .25rem;
outline: .25rem solid;
}
}
}
/* Click animation */
.postlink:active,
.taglink:active {
box-shadow: none;
top: .2rem;
left: .2rem;
box-shadow: .15rem .15rem var(--color-shadow);
}
/* Post & tag elements */
.post h2, .post img,
.post ul, .post li,
.tag h2, .tag p,
.tag img {
margin: 0;
}
.post h2,
.tag h2 {
grid-area: h2;
padding: .25rem .5rem;
text-transform: uppercase;
font-size: 1.5rem;
color: var(--color-primary);
border-radius: 1rem 1rem 0 0;
border-bottom: .25rem solid var(--color-accent);
}
.post:nth-child(even) h2,
.tag:nth-child(even) h2 {
text-align: right;
}
.postlink:focus-visible h2,
.taglink:focus-visible h2 {
color: var(--color-bg);
border-color: var(--color-bg);
}
@media (any-hover: hover) {
.postlink:hover h2,
.taglink:hover h2 {
color: var(--color-bg);
border-color: var(--color-bg);
}
}
/* Images */
.post img,
.tag-imgs {
grid-area: img;
}
.blur {
filter: blur(.75rem);
clip-path: inset(0 round 1rem);
}
.tag-imgs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: .15rem;
}
.tag-imgs img {
aspect-ratio: 3 / 2;
object-fit: cover;
}
.missing-image {
width: 100%;
aspect-ratio: 3 / 2;
background-color: var(--color-bg-alt);
border-radius: 1rem;
}
.taglink:focus-visible .missing-image {
opacity: .7;
}
@media (any-hover: hover) {
.taglink:hover .missing-image {
opacity: .7;
}
}
/* Post tags */
.postlist-tags {
grid-area: info;
list-style: none;
display: flex;
flex-flow: row wrap;
gap: .5rem;
padding: .5rem;
}
.post:nth-child(odd) .postlist-tags {
justify-content: flex-end;
}
.postlist-tags li,
.tagcount {
background-color: var(--color-primary);
color: var(--color-bg);
padding: 0 .5rem;
border-radius: 1rem;
}
.postlink:focus-visible .postlist-tags li,
.taglink:focus-visible .tagcount {
background-color: var(--color-bg);
color: var(--color-primary);
}
@media (any-hover: hover) {
.postlink:hover .postlist-tags li,
.taglink:hover .tagcount {
background-color: var(--color-bg);
color: var(--color-primary);
}
}
/* Tag count */
.tag p {
grid-area: info;
padding: .5rem;
}
.tag:nth-child(odd) p {
text-align: right;
}
img[tabindex="0"] {
cursor: pointer;
}
img[tabindex="0"]:focus-visible {
outline: .15rem solid var(--color-teal);
}
dialog {
margin: auto;
flex-flow: column;
background: transparent;
border: none;
position: absolute;
}
dialog[open] {
display: flex;
}
dialog::backdrop {
background-color: rgba(from var(--color-bg) r g b / .8);
backdrop-filter: blur(4px);
}
body:has(dialog[open]) {
overflow: hidden;
}
.close-dialog {
background-color: rgba(from var(--color-teal) r g b / .35);
font-size: 1.5rem;
padding: 0 .5rem .15rem;
border-radius: 1rem;
color: var(--color-teal);
box-shadow: .15rem .15rem var(--color-shadow);
border: .08rem solid var(--color-teal);
align-self: flex-end;
justify-self: flex-start;
margin: 0 .1rem;
/* Click animation handling */
position: relative;
top: 1rem;
left: -.15rem;
transition: top .1s ease-in, left .1s ease-in;
}
.close-dialog:focus-visible {
outline: none;
background-color: var(--color-teal);
color: var(--color-bg);
}
@media (any-hover: hover) {
.close-dialog:hover {
outline: none;
background-color: var(--color-teal);
color: var(--color-bg);
}
}
@media (forced-colors: active) {
.close-dialog:focus-visible {
outline-offset: .08rem;
outline: .08rem solid;
}
@media (any-hover: hover) {
.close-dialog:hover {
outline-offset: .08rem;
outline: .08rem solid;
}
}
}
/* Click animation */
.close-dialog:active {
top: 1.1rem;
left: -.05rem;
box-shadow: .05rem .05rem var(--color-shadow);
}
.modal-img {
max-height: calc(90vh - 2rem);
object-fit: contain;
width: auto;
max-width: 90%;
margin: 0 auto;
}
:root {
color-scheme: light dark;
--font-family: 'Atkinson Hyperlegible Next', sans-serif;
--font-family-code: 'Atkinson Hyperlegible Mono', monospace;
--color-dark: #2e303e;
--color-dark-alt: #3c3f52;
--color-light: #ebeeef;
--color-light-alt: #dbe1e3;
--color-teal-dark: #18737b;
--color-teal-light: #25b0bc;
--color-pink-dark: #94195d;
--color-pink-light: #ee9fcb;
--color-shadow: rgba(2, 10, 40, .25);
/* Used for syntax highlighting */
--color-red-light: #f195aa;
--color-orange-light: #fab795;
--color-yellow-light: #fbe6bc;
--color-green-light: #29d398;
--color-blue-light: #26bbd9;
--color-purple-light: #ddaeea;
--color-grey-light: #b9c3c6;
--color-red-dark: #991433;
--color-orange-dark: #883206;
--color-yellow-dark: #6a4906;
--color-green-dark: #125940;
--color-blue-dark: #125663;
--color-purple-dark: #722999;
--color-grey-dark: #4a4b64;
--color-text: light-dark(var(--color-dark), var(--color-light));
--color-bg: light-dark(var(--color-light), var(--color-dark));
--color-text-alt: light-dark(var(--color-dark-alt), var(--color-light-alt));
--color-bg-alt: light-dark(var(--color-light-alt), var(--color-dark-alt));
--color-teal: light-dark(var(--color-teal-dark), var(--color-teal-light));
--color-pink: light-dark(var(--color-pink-dark), var(--color-pink-light));
--color-red: light-dark(var(--color-red-dark), var(--color-red-light));
--color-orange: light-dark(var(--color-orange-dark), var(--color-orange-light));
--color-yellow: light-dark(var(--color-yellow-dark), var(--color-yellow-light));
--color-green: light-dark(var(--color-green-dark), var(--color-green-light));
--color-blue: light-dark(var(--color-blue-dark), var(--color-blue-light));
--color-purple: light-dark(var(--color-purple-dark), var(--color-purple-light));
--color-grey: light-dark(var(--color-grey-dark), var(--color-grey-light));
}
/* Base */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
color: var(--color-text);
background-color: var(--color-bg);
}
main {
width: 60vw;
max-width: 1000px;
margin: 0 auto;
scroll-margin-top: 7rem;
}
@media (max-width: 1050px) {
main {
width: 75vw;
}
}
@media (max-width: 650px) {
main {
width: 92vw;
}
}
/* Headers */
h1, h2, h3, h4, h5, h6 {
line-height: 1.25;
color: var(--color-teal);
}
h1 {
margin-top: 3rem;
font-size: 3.5rem;
text-align: center;
}
h2, h3, h4, h5, h6 {
scroll-margin-top: 5rem;
}
h2 {
margin-top: 2rem;
font-size: 2.2rem;
}
h3 {
margin-top: 1.5rem;
font-size: 1.6rem;
}
@media (max-width: 650px) {
h1 { font-size: 2.8rem; }
h2 { font-size: 1.8rem; }
h3 { font-size: 1.35rem; }
}
h4, h5, h6 {
margin-top: 1rem;
font-size: 1.2rem;
}
/* Images */
img {
display: block;
max-width: 100%;
height: auto;
border-radius: 1rem;
margin: 0 auto;
}
/* Paragraphs */
p {
margin: 1.25rem 0;
line-height: 1.4;
}
strong,
b {
font-weight: 900;
}
/* Links */
a {
color: var(--color-text);
border-radius: .25rem;
text-decoration: underline;
text-decoration-style: solid;
text-decoration-thickness: .2em;
text-decoration-color: var(--color-teal);
transition: text-decoration-thickness .5s;
}
a:focus-visible {
text-decoration: none;
outline: .15rem solid var(--color-teal);
}
@media (any-hover: hover) {
a:hover {
text-decoration-thickness: .4em;
}
}
a:active {
text-decoration-thickness: .4em;
}
/* Heading anchors */
a.ha,
span.ha-placeholder {
color: var(--color-pink);
font-size: .8em;
vertical-align: .1em;
}
span.ha-placeholder {
opacity: .55;
}
/* Lists */
::marker {
color: var(--color-pink);
}
ul, ol, dl, li {
margin-left: 1rem;
}
@media (max-width: 650px) {
ul, ol, dl {
margin-left: 0;
}
}
li {
line-height: 1.2 5;
margin-top: .65rem;
margin-bottom: .65rem;
}
li ul, li ol {
margin: .5rem 0;
}
dt {
font-weight: 900;
margin-top: .5rem;
}
dd {
margin-left: 2rem;
margin-bottom: .75rem;
}
/* Blockquotes */
blockquote {
margin: .5rem 0;
padding: 0 1rem;
border-radius: .25rem 1rem 1rem .25rem;
line-height: 1.25;
border-left: .5rem solid var(--color-pink);
}
blockquote,
blockquote p,
blockquote ol,
blockquote ul {
background-color: var(--color-bg-alt);
padding: .5rem;
}
blockquote p {
margin: 0;
}
/* Tables */
table {
width: 100%;
border-spacing: 0; /* border collapse doesn't play nice with radii */
border-radius: .3rem;
border: thin solid var(--color-pink);
}
th {
color: var(--color-bg);
background-color: var(--color-pink);
}
th code {
color: var(--color-text); /* Yes, I actually do this somewhere */
}
th, td {
padding: .5rem;
text-align: left;
}
tr:nth-child(even) { background-color: var(--color-bg-alt); }
th:not(:first-child) { border-left: thin solid var(--color-bg); }
th:first-child { border-top-left-radius: .25rem; }
th:last-child { border-top-right-radius: .25rem; }
td:not(:first-child) { border-left: thin solid var(--color-pink); }
/* Times */
time {
color: var(--color-grey);
}
/* Horizontal rules */
hr {
border: .25rem solid var(--color-pink);
margin: 2rem 0;
}
/* Used on home, reference, gallery pages */
.centered {
text-align: center;
}
/* Currently only used for resume, but it's generalizable */
.upper {
text-transform: uppercase;
}
/* Header */
header {
position: sticky;
top: 0;
background-color: var(--color-bg);
padding: .75rem 0;
z-index: 10;
border-bottom: thick solid var(--color-teal);
box-shadow: 0 .25rem .15rem var(--color-shadow);
}
/* Header links, pagination links */
header a,
.pagination a,
.webring ul a {
border-radius: 1rem;
border: .125rem solid var(--color-pink);
color: var(--color-pink);
text-decoration: none;
padding: 0 .25rem;
box-shadow: .15rem .15rem var(--color-shadow);
font-size: 1.2rem;
/* Click animation handling */
position: relative;
top: 0;
left: 0;
transition: top .05s ease-in, left .05s ease-in;
}
header a,
.pagination .older a,
.webring .prev a,
.webring .rand a {
padding-right: .35rem;
}
.pagination .newer a,
.webring .next a,
.webring .rand a {
padding-left: .35rem;
}
header a:focus-visible,
.pagination a:focus-visible,
.webring ul a:focus-visible {
color: var(--color-bg);
border-color: var(--color-pink);
background-color: var(--color-pink);
outline: none;
}
@media (any-hover: hover) {
header a:hover,
.pagination a:hover,
.webring ul a:hover {
color: var(--color-bg);
border-color: var(--color-pink);
background-color: var(--color-pink);
}
}
@media (forced-colors: active) {
header a:focus-visible,
.pagination a:focus-visible,
.webring ul a:focus-visible {
outline-offset: .125rem;
outline: .125rem solid;
}
@media (any-hover: hover) {
header a:hover,
.pagination a:hover,
.webring ul a:hover {
outline-offset: .125rem;
outline: .125rem solid;
}
}
}
/* Click animation */
header a:active,
.pagination a:active,
.webring ul a:active {
top: .1rem;
left: .1rem;
box-shadow: .05rem .05rem var(--color-shadow);
}
/* Current page */
header a[aria-current="page"] {
border-color: var(--color-teal);
color: var(--color-teal);
}
header a[aria-current="page"]:focus-visible {
color: var(--color-bg);
border-color: var(--color-teal);
background-color: var(--color-teal);
}
@media (any-hover: hover) {
header a[aria-current="page"]:hover {
color: var(--color-bg);
background-color: var(--color-teal);
border-color: var(--color-teal);
}
}
/* Header link icons, pagination icons */
header i,
.pagination i,
.webring ul i {
color: var(--color-teal);
}
header i,
.pagination .older i,
.webring .prev i,
.webring .rand i:nth-child(1) {
padding-left: .25rem;
}
.pagination .newer i,
.webring .next i,
.webring .rand i:nth-child(2) {
padding-right: .25rem;
}
header a[aria-current="page"] i {
color: var(--color-pink);
}
header a:focus-visible i,
a[aria-current="page"] a:focus-visible i,
.pagination a:focus-visible i,
.webring ul a:focus-visible i {
color: var(--color-bg);
}
@media (any-hover: hover) {
header a:hover i,
header a[aria-current="page"]:hover i,
.pagination a:hover i,
.webring ul a:hover i {
color: var(--color-bg);
}
}
/* Skip link */
#skip {
left: -999px;
position: absolute;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
z-index: -99;
}
#skip:focus-visible {
display: inline-block;
left: auto;
top: auto;
width: auto;
height: auto;
overflow: auto;
margin: 0 10%;
z-index: 999;
}
/* Nav */
header ul {
display: flex;
list-style: none;
gap: 1rem;
justify-content: center;
}
header ul,
header li {
margin: 0;
}
@media (max-width: 650px) {
.menu-text {
display: none; /* Icons only on small screens */
}
header a {
padding: .15rem .5rem;
}
header i {
padding: 0;
}
}
/* Footer */
footer {
padding: 1rem 0;
font-size: .9rem;
border-top: thick solid var(--color-pink);
margin-top: 2rem;
}
footer ul {
display: flex;
list-style: none;
gap: .5rem;
justify-content: center;
margin: 0;
}
footer li {
margin: 0;
}
footer li:nth-child(2)::before,
footer li:nth-child(2)::after {
content: " ● " / "";
color: var(--color-teal);
}
@media (max-width: 650px) {
footer ul {
flex-flow: column;
text-align: center;
}
footer li:nth-child(2)::before,
footer li:nth-child(2)::after {
content: none;
}
}
footer a {
text-decoration-color: var(--color-pink);
}
footer a:focus-visible {
outline-color: var(--color-pink);
}
/* Pagination */
.pagination,
.pagination li {
margin: 0;
}
.pagination {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-areas: "older newer";
list-style: none;
margin-top: 3rem;
}
@media (max-width: 650px) {
.post-pagination {
grid-template-columns: 1fr;
grid-template-areas:
"older"
"newer";
gap: .75rem;
}
}
.pagination .older {
grid-area: older;
}
.pagination .newer {
grid-area: newer;
text-align: right;
}
/* webring navigation */
.webring {
margin-bottom: 3rem;
}
.webring ul {
display: flex;
flex-flow: row wrap;
list-style: none;
justify-content: space-around;
gap: .35rem;
}
.webring ul,
.webring li {
margin-left: 0;
}
@media print {
/* Nav elements */
header,
footer,
nav {
display: none !important;
}
/* Base */
body {
background-color: #fff;
color: #000;
}
main {
width: 95vw;
}
h1,h2,h3,h4,h5,h6 {
color: #000;
}
/* Links */
/* Hover is not really necessary, but it's annoying when testing otherwise */
a,
a:hover {
text-decoration-style: dotted;
text-decoration-thickness: .1rem;
text-decoration-color: #000;
}
a::after{
content: " (" attr(href) ")";
}
/* Code */
code,
pre,
code[class*=language-],
pre[class*=language-] {
text-shadow: none;
background-color: var(--color-light);
color: #000 !important;
}
.token.namespace {
opacity: 1;
}
.token.block-comment,
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog,
.token.punctuation,
.token.attr-name,
.token.deleted,
.token.namespace,
.token.tag,
.token.boolean,
.token.function,
.token.number,
.token.class-name,
.token.constant,
.token.property,
.token.symbol,
.token.attr-value,
.token.char,
.token.regex,
.token.string,
.token.variable,
.token.inserted,
.token.entity,
.token.operator,
.token.url,
.token.function-name,
.token.atrule,
.token.builtin,
.token.important,
.token.keyword,
.token.selector {
color: #000;
}
}< / style >
< script type = "module" > / / T h a n k y o u t o h t t p s : / / g i t h u b . c o m / d a v i d d a r n e s / h e a d i n g - a n c h o r s
// Thank you to https://amberwilson.co.uk/blog/are-your-anchor-links-accessible/
let globalInstanceIndex = 0;
class HeadingAnchors extends HTMLElement {
static register(tagName = "heading-anchors", registry = window.customElements) {
if(registry & & !registry.get(tagName)) {
registry.define(tagName, this);
}
}
static attributes = {
exclude: "data-ha-exclude",
prefix: "prefix",
content: "content",
}
static classes = {
anchor: "ha",
placeholder: "ha-placeholder",
srOnly: "ha-visualhide",
}
static defaultSelector = "h2,h3,h4,h5,h6";
static css = `
.${HeadingAnchors.classes.srOnly} {
clip: rect(0 0 0 0);
height: 1px;
overflow: hidden;
position: absolute;
width: 1px;
}
.${HeadingAnchors.classes.anchor} {
position: absolute;
left: var(--ha_offsetx);
top: var(--ha_offsety);
text-decoration: none;
opacity: 0;
}
.${HeadingAnchors.classes.placeholder} {
opacity: .3;
}
.${HeadingAnchors.classes.anchor}:is(:focus-within, :hover) {
opacity: 1;
}
.${HeadingAnchors.classes.anchor},
.${HeadingAnchors.classes.placeholder} {
display: inline-block;
padding: 0 .25em;
/* Disable selection of visually hidden label */
-webkit-user-select: none;
user-select: none;
}
@supports (anchor-name: none) {
.${HeadingAnchors.classes.anchor} {
position: absolute;
left: anchor(left);
top: anchor(top);
}
}`;
get supports() {
return "replaceSync" in CSSStyleSheet.prototype;
}
get supportsAnchorPosition() {
return CSS.supports("anchor-name: none");
}
constructor() {
super();
if(!this.supports) {
return;
}
let sheet = new CSSStyleSheet();
sheet.replaceSync(HeadingAnchors.css);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
this.headingStyles = {};
this.instanceIndex = globalInstanceIndex++;
}
connectedCallback() {
if (!this.supports) {
return;
}
this.headings.forEach((heading, index) => {
if(!heading.hasAttribute(HeadingAnchors.attributes.exclude)) {
let anchor = this.getAnchorElement(heading);
let placeholder = this.getPlaceholderElement();
// Prefers anchor position approach for better accessibility
// https://amberwilson.co.uk/blog/are-your-anchor-links-accessible/
if(this.supportsAnchorPosition) {
let anchorName = `--ha_${this.instanceIndex}_${index}`;
placeholder.style.setProperty("anchor-name", anchorName);
anchor.style.positionAnchor = anchorName;
}
heading.appendChild(placeholder);
heading.after(anchor);
}
});
}
// Polyfill-only
positionAnchorFromPlaceholder(placeholder) {
if(!placeholder) {
return;
}
let heading = placeholder.closest("h1,h2,h3,h4,h5,h6");
if(!heading.nextElementSibling) {
return;
}
// TODO next element could be more defensive
this.positionAnchor(heading.nextElementSibling);
}
// Polyfill-only
positionAnchor(anchor) {
if(!anchor || !anchor.previousElementSibling) {
return;
}
// TODO previous element could be more defensive
let heading = anchor.previousElementSibling;
this.setFontProp(heading, anchor);
if(this.supportsAnchorPosition) {
// quit early
return;
}
let placeholder = heading.querySelector(`.${HeadingAnchors.classes.placeholder}`);
if(placeholder) {
anchor.style.setProperty("--ha_offsetx", `${placeholder.offsetLeft}px`);
anchor.style.setProperty("--ha_offsety", `${placeholder.offsetTop}px`);
}
}
setFontProp(heading, anchor) {
let placeholder = heading.querySelector(`.${HeadingAnchors.classes.placeholder}`);
if(placeholder) {
let style = getComputedStyle(placeholder);
let props = ["font-weight", "font-size", "line-height", "font-family"];
let [weight, size, lh, family] = props.map(name => style.getPropertyValue(name));
anchor.style.setProperty("font", `${weight} ${size}/${lh} ${family}`);
let vars = style.getPropertyValue("font-variation-settings");
if(vars) {
anchor.style.setProperty("font-variation-settings", vars);
}
}
}
/* no longer used, using aria-labelledby instead - see getAnchorElement
getAccessibleTextPrefix() {
// Useful for i18n
return this.getAttribute(HeadingAnchors.attributes.prefix) || "Jump to section titled";
}
*/
getContent() {
if(this.hasAttribute(HeadingAnchors.attributes.content)) {
return this.getAttribute(HeadingAnchors.attributes.content);
}
return "#";
}
// Placeholder nests inside of heading
getPlaceholderElement() {
let ph = document.createElement("span");
ph.setAttribute("aria-hidden", true);
ph.classList.add(HeadingAnchors.classes.placeholder);
let content = this.getContent();
if(content) {
ph.innerHTML = content; // CHANGED HERE
}
ph.addEventListener("mouseover", (e) => {
let placeholder = e.target.closest(`.${HeadingAnchors.classes.placeholder}`);
if(placeholder) {
this.positionAnchorFromPlaceholder(placeholder);
}
});
return ph;
}
getAnchorElement(heading) {
let anchor = document.createElement("a");
anchor.href = `#${heading.id}`;
anchor.classList.add(HeadingAnchors.classes.anchor);
let content = this.getContent();
anchor.innerHTML = `< span aria-labelledby = ${heading.id} > ${content}< / span > `; // CHANGED HERE
anchor.addEventListener("focus", e => {
let anchor = e.target.closest(`.${HeadingAnchors.classes.anchor}`);
if(anchor) {
this.positionAnchor(anchor);
}
});
anchor.addEventListener("mouseover", (e) => {
// when CSS anchor positioning is supported, this is only used to set the font
let anchor = e.target.closest(`.${HeadingAnchors.classes.anchor}`);
this.positionAnchor(anchor);
});
return anchor;
}
get headings() {
return this.querySelectorAll(this.selector.split(",").map(entry => `${entry.trim()}[id]`));
}
get selector() {
return this.getAttribute("selector") || HeadingAnchors.defaultSelector;
}
}
HeadingAnchors.register();
export { HeadingAnchors }
/* don't even bother on mobile */
if (window.innerWidth > 650) {
const dialog = document.querySelector("dialog");
const closeButton = document.querySelector(".close-dialog");
const hero = document.querySelector(".hero");
hero.tabIndex = 0;
hero.addEventListener("click", (e) => dialog.showModal());
hero.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
dialog.showModal();
}
});
closeButton.addEventListener("click", (e) => dialog.close());
closeButton.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
dialog.close();
}
});
}< / script >
< / head >
< body >
< header >
< a href = "#main" id = "skip" title = "skip to main content" >
< i class = "fa-solid fa-forward" aria-hidden = "true" > < / i > skip
< / a >
< nav aria-label = "main navigation" >
< ul >
< li >
< a href = "/reference/" title = "read reference posts" >
< i class = "fa-regular fa-folder-open" aria-hidden = "true" > < / i >
< span class = "menu-text" > reference< / span >
< / a >
< / li >
< li >
< a href = "/gallery/" title = "view the gallery" >
< i class = "fa-regular fa-images" aria-hidden = "true" > < / i >
< span class = "menu-text" > gallery< / span >
< / a >
< / li >
< li >
< a href = "/" title = "home" >
< i class = "fa fa-solid fa-crow" aria-hidden = "true" > < / i >
< span class = "menu-text" > home< / span >
< / a >
< / li >
< li >
< a href = "/about/" title = "about Lee" >
< i class = "fa-regular fa-user" aria-hidden = "true" > < / i >
< span class = "menu-text" > about< / span >
< / a >
< / li >
< li >
< a href = "/contact/" title = "contact Lee" >
< i class = "fa-solid fa-envelope-open-text" aria-hidden = "true" > < / i >
< span class = "menu-text" > contact< / span >
< / a >
< / li >
< / ul >
< / nav >
< / header >
< main id = "main" >
< heading-anchors content = "<i class='fa-solid fa-anchor'></i>" >
< article >
< h1 id = "accessible-image-modals" > accessible image modals< / h1 >
< div class = "post-metadata" >
< p >
posted on
< time datetime = "2026-03-22" > March 22, 2026< / time >
by Lee Cattarin
< / p >
< ul class = "post-tags" >
< li >
< a href = "/tags/software/" > software< / a >
< / li >
< / ul >
< / div >
< dialog closedby = "any" aria-label = "image modal" tabindex = "-1" >
< button class = "close-dialog" autofocus = "" aria-label = "close the image modal" > × < / button >
< img class = "modal-img" src = "/img/snacking-seagull.jpg" alt = "Image unrelated to post. A seagull floating in the water with a starfish hanging out of eir mouth." loading = "lazy" decoding = "async" width = "1000" height = "666" >
< / dialog >
< img class = "hero" src = "/img/snacking-seagull.jpg" alt = "Image unrelated to post. A seagull floating in the water with a starfish hanging out of eir mouth." loading = "lazy" decoding = "async" width = "1000" height = "666" >
< p > Recently I've been working on a single-page digital rendition of a zine complete with many hand-drawn images. The author wanted to be able to bring images up to a full-screen view, to either zoom in or to put the whole enlarged image on one screen with no scrolling. It was a real struggle to find resources on how to do this in an accessible manner, so I'm writing up what I did.< / p >
< blockquote >
< p > Fair warning: This solution is likely imperfect.< / p >
< / blockquote >
< h2 id = "the-dialog-element" > the < code > dialog< / code > element< / h2 >
< p > do you know how many tutorials want you to roll your own modals? It's a not-insignificant amount. W3Schools, top of the search results in many cases, recommends it in two places - < a href = "https://www.w3schools.com/howto/howto_css_modal_images.asp" target = "_blank" rel = "external" > image modals< / a > and < a href = "https://www.w3schools.com/css/css3_images_modal.asp" target = "_blank" rel = "external" > responsive image modals< / a > . Several search results for " image modal" pop up div solutions - < a href = "https://stackoverflow.com/questions/75598914/how-to-display-an-image-clicking-on-it-using-modal-window-on-html-css-and-js" target = "_blank" rel = "external" > div modal 1< / a > , < a href = "https://dev.to/salehmubashar/create-an-image-modal-with-javascript-2lf3" target = "_blank" rel = "external" > div modal 2< / a > , < a href = "https://www.youtube.com/watch?v=Y9TNHynFjaQ" target = "_blank" rel = "external" > div modal 3< / a > .< / p >
< p > If you don't know of the existence of < code > < dialog> < / code > , a search for image modals will not get you there quickly.< / p >
< p > If you search for < em > accessible< / em > image modals, you'll still hear about < code > < div> < / code > s. Hell, < a href = "https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/dialog/" target = "_blank" rel = "external" > W3C's ARIA Authoring Practices Guide uses a < code > < div> < / code > < / a > . You kinda have to go digging to read about the < a href = "https://www.scottohara.me/blog/2023/01/26/use-the-dialog-element.html" target = "_blank" rel = "external" > dialog element from Scott O'Hara< / a > or find < a href = "https://accessibleweb.dev/modals" target = "_blank" rel = "external" > AccessibleWeb.dev's piece on modals< / a > . Or you can go < a href = "https://adrianroselli.com/2025/06/where-to-put-focus-when-opening-a-modal-dialog.html" target = "_blank" rel = "external" > straight for Adrian Roselli< / a > and find examples that use the native < code > < dialog> < / code > element. Thanks Adrian!< / p >
< p > Did I roll my own modal at first? Regretfully, yes. I'd used the < code > < dialog> < / code > element before... several years ago... in a project I don't have access to anymore... Needless to say, I had forgotten about its existence.< / p >
< p > Anyway, I got there. Eventually. So let's talk about modals and < code > < dialog> < / code > .< / p >
< h3 id = "what-does-dialog-give-us" > what does < code > < dialog> < / code > give us?< / h3 >
< p > so, what do we get from the < code > < dialog> < / code > element that we don't get from a < code > < div> < / code > modal?< / p >
< ol >
< li > a semantically meaningful element< / li >
< li > a backdrop that fills the screen behind the modal, styleable with < code > ::backdrop< / code > < / li >
< li > automatic use of the < code > Esc< / code > key to close the modal< / li >
< li > automatic focus trapping that prevents any tabbing within the page behind the modal (users can tab off the page into the browser buttons)< / li >
< li > the JS functions < code > .showModal()< / code > and < code > .close()< / code > < / li >
< li > if < code > closedby< / code > is set to < code > any< / code > , clicking outside the modal (anywhere on the backdrop) will also close the modal< / li >
< li > automatic return of focus to the element that triggered the modal< / li >
< li > the < code > autofocus< / code > attribute for in-modal elements to set which element should receive focus on opening the modal< / li >
< li > the < code > open< / code > attribute which is set on the < code > < dialog> < / code > when open and removed when closed (when you use the functions mentioned previously)< / li >
< / ol >
< p > and more. This is just the pieces in use for me. There's also things like < a href = "https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog#additional_notes" target = "_blank" rel = "external" > setting forms so that submission closes the dialog< / a > (point 1 in that list).< / p >
< h3 id = "what-doesnt-it-give-us" > what doesn't it give us?< / h3 >
< ol >
< li > a close button - gotta roll your own and attach the requisite < code > .close()< / code > call (or rely on users hitting escape or clicking the backdrop)< / li >
< li > prevention of scroll on the rest of the page< / li >
< / ol >
< h3 id = "a-bug" > a bug< / h3 >
< p > MDN warns:< / p >
< blockquote >
< p > Do not add the < code > tabindex< / code > property to the < code > < dialog> < / code > element as it is not interactive and does not receive focus.< / p >
< / blockquote >
< p > — < a href = "https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog#additional_notes" target = "_blank" rel = "external" > < code > < dialog> < / code > : additional notes< / a > (point 3 in that list).< / p >
< p > despite this, I found that in Firefox (but not Edge), the < em > < code > < dialog> < / code > itself was focusable< / em > . I have no idea why, but I tested this on a totally unstyled and unmodified page and still found it to be true. As a focusable element, it made no sense. It had no interactivity and could not be activated.< / p >
< p > I'm still torn: do I add < code > tabindex=" -1" < / code > ? MDN specifically says not to, but I'm pretty sure they're warning against making it < em > focusable< / em > . Why warn against making it nonfocusable when < em > it's not supposed to be focusable in the first place< / em > , after all?< / p >
< p > At current, I'm ambivalent, but I've added < code > tabindex=" -1" < / code > to handle Firefox's poor behavior. Making the dialog focusable is unhelpful and confusing.< / p >
< h2 id = "in-addition-to-the-dialog" > in addition to the < code > < dialog> < / code > < / h2 >
< p > here's what else I wrote...< / p >
< h3 id = "html" > html< / h3 >
< p > besides the < code > < dialog> < / code > , I gave my < code > < img> < / code > elements < code > tabindex=" 0" < / code > to make them focusable.< / p >
< h3 id = "js" > js< / h3 >
< p > in < code > modal.js< / code > , I created an < code > openDialog()< / code > function that takes in the clicked image. It:< / p >
< ul >
< li > creates a new < code > < img> < / code > element and copies over the < code > src< / code > and < code > alt< / code > attributes - importantly, it doesn't copy the full < code > < img> < / code > because we < em > don't< / em > want to copy that < code > tabindex< / code > attribute< / li >
< li > replaces the current < code > < img> < / code > in the modal with our new copy with < code > replaceChild()< / code > (or if there's no current one, it just appends)< / li >
< li > calls < code > dialog.showModal()< / code > < / li >
< / ul >
< p > I gave my close button two event listeners, one that listens for click events and one that listens for a keydown of the space or enter keys. In the case of the keydown, it calls < code > event.preventDefault()< / code > to stop the space key from scrolling the underlying page.< / p >
< p > I also looped through all images and attached my < code > openDialog()< / code > function to any image with a < code > tabindex< / code > attribute (I had some images that weren't intended to be fullscreened, so they lacked < code > tabindex< / code > ). Again, I gave them listeners on both click and keydown.< / p >
< h3 id = "css" > css< / h3 >
< p > here's the most relevant parts of the CSS:< / p >
< ul >
< li > < code > dialog::backdrop< / code > was given a < a href = "https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Colors/Using_relative_colors" target = "_blank" rel = "external" > relatively calculated< / a > color - < code > rgba(from var(--color-bg) r g b / .8)< / code > - as well as a blur< / li >
< li > < code > body:has(dialog[open])< / code > has < code > overflow: hidden< / code > set< / li >
< li > < code > dialog img< / code > uses a < code > max-height< / code > as well as < code > object-fit: contain< / code > < / li >
< / ul >
< h2 id = "errors-questions" > errors? questions?< / h2 >
< p > reach out!< / p >
< / article >
< nav aria-label = "pagination" >
< ol class = "pagination post-pagination" >
< li class = "older" >
< a href = "/eleventy-lessons/" >
< i class = "fa-solid fa-hand-point-left" aria-hidden = "true" > < / i > eleventy lessons
< / a >
< / li >
< li class = "newer" >
< a href = "/fedilearns/" >
fediLearns < i class = "fa-solid fa-hand-point-right" aria-hidden = "true" > < / i >
< / a >
< / li >
< / ol >
< / nav >
< hr >
< section class = "related-posts" >
< h2 data-ha-exclude = "" id = "related-posts" > related posts< / h2 >
< ol id = "postlist" >
< li class = "post" >
2026-07-26 21:52:19 +00:00
< a class = "postlink" href = "/core-css/" >
< h2 data-ha-exclude = "" id = "core-css" > core CSS < / h2 >
2026-05-15 09:20:43 -07:00
< ul class = "postlist-tags" >
< li > software< / li >
< / ul >
2026-07-26 21:52:19 +00:00
< img src = "/img/core-css.png" alt = "A website mockup in dark mode with light pink accents. Everything is filler text such as the title 'This is my cool website', headers reading things like 'This is an h1', nav items 'Nav item 1', etc. There is a paragraph block - the opening of the Bee Movie - and part of an image visible - a screencap of the music video for Never Gonna Give You Up by Rick Astley." loading = "lazy" decoding = "async" width = "1000" height = "447" >
2026-05-15 09:20:43 -07:00
< / a >
< / li >
< li class = "post" >
2026-07-26 21:52:19 +00:00
< a class = "postlink" href = "/moving-images/" >
< h2 data-ha-exclude = "" id = "moving-images" > moving images < / h2 >
2026-05-15 09:20:43 -07:00
< ul class = "postlist-tags" >
< li > software< / li >
< / ul >
2026-07-26 21:52:19 +00:00
< img src = "/img/cormorant.jpg" alt = "Image unrelated to post. A cormorant, a type of black waterfowl, poses with wings spread on a buoy in Puget Sound. Off to the left, another bird floats." loading = "lazy" decoding = "async" width = "1000" height = "666" >
2026-05-15 09:20:43 -07:00
< / a >
< / li >
< li class = "post" >
2026-07-26 21:52:19 +00:00
< a class = "postlink" href = "/redirections/" >
< h2 data-ha-exclude = "" id = "redirections" > redirections < / h2 >
2026-05-15 09:20:43 -07:00
< ul class = "postlist-tags" >
< li > software< / li >
< / ul >
2026-07-26 21:52:19 +00:00
< img src = "/img/angle-brackets-uwu.jpg" alt = "Ascii art of an emoticon with pinched eyes and a small mouth made with two angle brackets." loading = "lazy" decoding = "async" width = "1000" height = "316" >
2026-05-15 09:20:43 -07:00
< / a >
< / li >
< / ol >
< / section >
< / heading-anchors >
< / main >
< footer >
< ul >
< li >
< a href = "/colophon/" >
colophon
< / a >
< / li >
< li >
< a href = "/" title = "go home" aria-label = "go home | hello hello from Lee Cattarin, copyright 2022-2026" >
hello hello from Lee Cattarin, © 2022-2026< / a >
< / li >
< li >
< a href = "https://heckin.technology/inherentlee/leecat.art" title = "source code" aria-label = "source code" target = "_blank" rel = "external" >
src
< / a >
< / li >
< / ul >
< / footer >
2026-07-26 21:52:19 +00:00
<!-- This page `/accessible - image - modals/` was built on 2026 - 07 - 26T21:52:03.855Z -->
2026-05-15 09:20:43 -07:00
< / body >
< / html >