Split Button
The main action on the left, the other options behind an arrow. One tint slides between the halves; pick another way to do it and the label rolls over, the button easing to its new width.
View codePress Share and the options slide out from behind it, the last one pushing the pill open, while the arrow takes off and a cross draws itself. Copy link turns into a check; X and LinkedIn open in a small window.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Share Button to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-share">
<button class="kodza-share__toggle" type="button" aria-expanded="false">
<svg class="kodza-share__glyph" viewBox="0 0 24 24" aria-hidden="true">
<path class="kodza-share__tray" d="M5 11.5V18a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6.5" pathLength="1" />
<path class="kodza-share__arrow" d="M12 14.5V3.8M7.8 8 12 3.8 16.2 8" />
<path class="kodza-share__cross" d="M6.8 6.8l10.4 10.4" pathLength="1" />
<path class="kodza-share__cross" d="M17.2 6.8 6.8 17.2" pathLength="1" />
</svg>
<span class="kodza-share__label"><span>Share</span></span>
</button>
<div class="kodza-share__options" role="group" aria-label="Share this page">
<button class="kodza-share__option" type="button" data-share="copy" aria-label="Copy link">
<svg viewBox="0 0 24 24" aria-hidden="true">
<g class="kodza-share__link"><path d="M9.6 14.4l4.8-4.8" /><path d="M11.2 6.6l1.3-1.3a4 4 0 0 1 5.7 5.7l-1.3 1.3" /><path d="M12.8 17.4l-1.3 1.3a4 4 0 0 1-5.7-5.7l1.3-1.3" /></g>
<path class="kodza-share__check" d="M5.5 12.5l4.2 4.2 8.8-9.2" pathLength="1" />
</svg>
</button>
<a class="kodza-share__option" data-share="x" href="https://x.com/intent/post?url={url}&text={title}" target="_blank" rel="noopener" aria-label="Post on X">
<svg class="kodza-share__brand" viewBox="0 0 24 24" aria-hidden="true"><path d="M17.7 3h3l-6.6 7.6L22 21h-6.1l-4.8-6.3L5.6 21h-3l7.1-8.1L2.2 3h6.2l4.4 5.8L17.7 3Zm-1 16.2h1.7L7.4 4.7H5.6l11.1 14.5Z" /></svg>
</a>
<a class="kodza-share__option" data-share="linkedin" href="https://www.linkedin.com/sharing/share-offsite/?url={url}" target="_blank" rel="noopener" aria-label="Share on LinkedIn">
<svg class="kodza-share__brand" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="5.2" r="2.2" /><path d="M3.2 9h3.6v11.5H3.2zM9.4 9h3.45v1.6c.55-1 1.9-1.95 3.85-1.95 3.65 0 4.3 2.4 4.3 5.5v6.35h-3.6v-5.6c0-1.35-.05-3.05-1.85-3.05-1.85 0-2.55 1.4-2.55 2.95v5.7H9.4z" /></svg>
</a>
<a class="kodza-share__option" data-share="email" href="mailto:?subject={title}&body={url}" aria-label="Send by email">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5.5" width="17" height="13" rx="2.5" /><path d="M4.5 7.5l7.5 5.5 7.5-5.5" /></svg>
</a>
</div>
<span class="kodza-share__status" role="status"></span>
</div>
<div class="kodza-share kodza-share--icon">
<button class="kodza-share__toggle" type="button" aria-expanded="false" aria-label="Share">
<svg class="kodza-share__glyph" viewBox="0 0 24 24" aria-hidden="true">
<path class="kodza-share__tray" d="M5 11.5V18a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6.5" pathLength="1" />
<path class="kodza-share__arrow" d="M12 14.5V3.8M7.8 8 12 3.8 16.2 8" />
<path class="kodza-share__cross" d="M6.8 6.8l10.4 10.4" pathLength="1" />
<path class="kodza-share__cross" d="M17.2 6.8 6.8 17.2" pathLength="1" />
</svg>
</button>
<div class="kodza-share__options" role="group" aria-label="Share this page">
<button class="kodza-share__option" type="button" data-share="copy" aria-label="Copy link">
<svg viewBox="0 0 24 24" aria-hidden="true">
<g class="kodza-share__link"><path d="M9.6 14.4l4.8-4.8" /><path d="M11.2 6.6l1.3-1.3a4 4 0 0 1 5.7 5.7l-1.3 1.3" /><path d="M12.8 17.4l-1.3 1.3a4 4 0 0 1-5.7-5.7l1.3-1.3" /></g>
<path class="kodza-share__check" d="M5.5 12.5l4.2 4.2 8.8-9.2" pathLength="1" />
</svg>
</button>
<a class="kodza-share__option" data-share="x" href="https://x.com/intent/post?url={url}&text={title}" target="_blank" rel="noopener" aria-label="Post on X">
<svg class="kodza-share__brand" viewBox="0 0 24 24" aria-hidden="true"><path d="M17.7 3h3l-6.6 7.6L22 21h-6.1l-4.8-6.3L5.6 21h-3l7.1-8.1L2.2 3h6.2l4.4 5.8L17.7 3Zm-1 16.2h1.7L7.4 4.7H5.6l11.1 14.5Z" /></svg>
</a>
<a class="kodza-share__option" data-share="linkedin" href="https://www.linkedin.com/sharing/share-offsite/?url={url}" target="_blank" rel="noopener" aria-label="Share on LinkedIn">
<svg class="kodza-share__brand" viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="5.2" r="2.2" /><path d="M3.2 9h3.6v11.5H3.2zM9.4 9h3.45v1.6c.55-1 1.9-1.95 3.85-1.95 3.65 0 4.3 2.4 4.3 5.5v6.35h-3.6v-5.6c0-1.35-.05-3.05-1.85-3.05-1.85 0-2.55 1.4-2.55 2.95v5.7H9.4z" /></svg>
</a>
<a class="kodza-share__option" data-share="email" href="mailto:?subject={title}&body={url}" aria-label="Send by email">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5.5" width="17" height="13" rx="2.5" /><path d="M4.5 7.5l7.5 5.5 7.5-5.5" /></svg>
</a>
</div>
<span class="kodza-share__status" role="status"></span>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Share Button
Press Share and the options slide out from behind the button, the last one pushing the pill
open, while the arrow of the icon takes off and a cross draws itself in its place. One tint
and one label glide from option to option; "Copy link" turns into a check and the label rolls
to "Link copied". The links fill in the page's URL and title ({url} and {title} in each href,
data-url and data-title on the root to pick others), X and LinkedIn open in a small window.
Everything is tuned with the --kz-share-* variables. */
.kodza-share {
--kz-share-bg: #0a0a0a;
--kz-share-fg: #ffffff;
--kz-share-border: transparent;
--kz-share-tint: rgba(255, 255, 255, 0.14); /* the hover, under one option */
--kz-share-tip-bg: #0a0a0a;
--kz-share-tip-fg: #ffffff;
--kz-share-height: 52px;
--kz-share-option: 44px;
--kz-share-duration: 0.6s; /* opening and closing */
--kz-share-unfold: cubic-bezier(0.65, 0, 0.35, 1);
--kz-share-ease: cubic-bezier(0.22, 1, 0.36, 1); /* the tint, the label, the small moves */
position: relative;
/* A number, not auto, so it can wait for the label to go before dropping back */
z-index: 0;
display: inline-flex;
align-items: center;
box-sizing: border-box;
height: var(--kz-share-height);
padding: calc((var(--kz-share-height) - var(--kz-share-option)) / 2);
border-radius: 999px;
background: var(--kz-share-bg);
box-shadow: inset 0 0 0 1.5px var(--kz-share-border);
color: var(--kz-share-fg);
vertical-align: middle;
-webkit-tap-highlight-color: transparent;
transition: z-index 0s linear 0.2s;
}
.kodza-share.is-open {
z-index: 20;
transition-delay: 0s;
}
/* Share: the icon and its word. Open, it closes down to a round cross (the script eases the
width, the word slips away to the left) */
.kodza-share__toggle {
display: inline-flex;
flex: none;
align-items: center;
gap: 8px;
box-sizing: border-box;
height: var(--kz-share-option);
margin: 0;
padding: 0 18px 0 12px;
overflow: hidden;
border: 0;
border-radius: 999px;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
white-space: nowrap;
cursor: pointer;
}
.kodza-share.is-open .kodza-share__toggle {
width: var(--kz-share-option);
}
/* Icon only: a round button */
.kodza-share--icon .kodza-share__toggle {
width: var(--kz-share-option);
padding: 0 13px;
}
.kodza-share__toggle:focus-visible,
.kodza-share__option:focus-visible {
outline: 2px solid var(--kz-share-fg);
outline-offset: -4px;
}
.kodza-share__toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* The word slips away to the left as the button closes over it */
.kodza-share__label {
flex: none;
}
.kodza-share__label > span {
display: block;
transition:
opacity 0.3s ease 0.2s,
transform var(--kz-share-duration) var(--kz-share-ease);
}
/* (gone before the options come out) */
.kodza-share.is-open .kodza-share__label > span {
opacity: 0;
transform: translateX(-10px);
transition:
opacity 0.15s ease,
transform var(--kz-share-duration) var(--kz-share-ease);
}
/* The icon: the arrow takes off out of its tray, a cross draws itself in its place */
.kodza-share__glyph,
.kodza-share__option svg {
display: block;
flex: none;
width: 18px;
height: 18px;
overflow: hidden;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Drawn and undrawn with the dash: hidden, the dash sits well inside its gap (a dash ending
right at the tip of the line would leave the dot of its round cap) */
.kodza-share__tray,
.kodza-share__cross,
.kodza-share__check {
stroke-dasharray: 1 2;
}
/* Open, the cross sits in the middle of its round button */
.kodza-share__glyph {
transition: transform var(--kz-share-duration) var(--kz-share-unfold);
}
.kodza-share.is-open .kodza-share__glyph {
transform: translateX(1px);
}
.kodza-share--icon .kodza-share__glyph,
.kodza-share--icon.is-open .kodza-share__glyph {
transform: none;
}
.kodza-share__tray {
transition: stroke-dashoffset 0.32s var(--kz-share-ease) 0.14s;
}
.kodza-share__arrow {
transition: transform 0.42s var(--kz-share-ease) 0.12s;
}
.kodza-share__cross {
stroke-dashoffset: 1.5;
transition: stroke-dashoffset 0.16s ease;
}
.kodza-share.is-open .kodza-share__tray {
stroke-dashoffset: -1.5;
transition: stroke-dashoffset 0.22s cubic-bezier(0.55, 0, 1, 0.45);
}
.kodza-share.is-open .kodza-share__arrow {
transform: translateY(-17px);
transition: transform 0.22s cubic-bezier(0.55, 0, 1, 0.45);
}
.kodza-share.is-open .kodza-share__cross {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.26s var(--kz-share-ease) 0.16s;
}
.kodza-share.is-open .kodza-share__cross + .kodza-share__cross {
transition-delay: 0.24s;
}
/* The options: all waiting behind the button, each slides out to its place, so the last one
rides the edge of the pill as it opens */
.kodza-share__options {
display: flex;
width: 0;
overflow: hidden;
/* Its right end rounded on the same circle as the end of the pill: an option one frame ahead
of the width never pokes out of it */
clip-path: inset(0 0 0 calc(var(--kz-share-option) * -1) round 0 calc(var(--kz-share-option) / 2) calc(var(--kz-share-option) / 2) 0);
visibility: hidden;
transition: visibility 0s linear var(--kz-share-duration);
}
.kodza-share.is-open .kodza-share__options {
width: auto;
visibility: visible;
transition-delay: 0s;
}
.kodza-share__option {
position: relative;
display: grid;
flex: none;
place-items: center;
width: var(--kz-share-option);
height: var(--kz-share-option);
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: inherit;
text-decoration: none;
cursor: pointer;
transform: translateX(calc((var(--kz-share-i, 0) + 1) * var(--kz-share-option) * -1));
}
.kodza-share.is-ready .kodza-share__option {
transition: transform var(--kz-share-duration) var(--kz-share-unfold);
}
.kodza-share.is-open .kodza-share__option {
transform: none;
}
/* On the move, each option is a disc of the button's colour: they slide out from under one
another like a fanned deck, the last one on top */
.kodza-share.is-moving .kodza-share__option {
background: var(--kz-share-bg);
}
/* Their icons come in as they leave the button, and fade on their way back into it */
.kodza-share__option svg {
opacity: 0;
transition: opacity 0.2s ease 0.12s;
}
.kodza-share.is-open .kodza-share__option svg {
opacity: 1;
transition: opacity 0.3s ease 0.08s;
}
/* The logos are solid: a touch smaller, to weigh as much as the line icons */
.kodza-share__option .kodza-share__brand {
width: 16px;
height: 16px;
fill: currentColor;
stroke: none;
}
/* Copied: the chain gives way to a check */
.kodza-share__link {
transform-origin: 12px 12px;
transition:
opacity 0.2s ease 0.1s,
transform 0.35s var(--kz-share-ease) 0.1s;
}
.kodza-share__check {
stroke-dashoffset: 1.5;
transition: stroke-dashoffset 0.2s ease;
}
.kodza-share__option.is-copied .kodza-share__link {
opacity: 0;
transform: rotate(-45deg) scale(0.6);
transition-delay: 0s;
}
.kodza-share__option.is-copied .kodza-share__check {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.32s var(--kz-share-ease) 0.12s;
}
/* One tint for the whole button, cut out by clip-path around the option under the pointer (the
script sends its left edge and width), so it glides from one to the next */
.kodza-share__tint {
position: absolute;
inset: calc((var(--kz-share-height) - var(--kz-share-option)) / 2);
z-index: -1;
border-radius: 999px;
background: var(--kz-share-tint);
opacity: var(--kz-share-tint-on, 0);
clip-path: inset(0 calc(100% - var(--kz-share-tint-l, 0px) - var(--kz-share-tint-w, 0px)) 0 var(--kz-share-tint-l, 0px) round 999px);
pointer-events: none;
}
.kodza-share.is-ready .kodza-share__tint {
transition: opacity 0.2s ease;
}
.kodza-share.is-ready.is-gliding .kodza-share__tint {
transition:
clip-path 0.3s var(--kz-share-ease),
opacity 0.2s ease;
}
/* While the button opens or closes, the script keeps the tint and the label on what they sit
on, frame by frame */
.kodza-share.is-ready.is-gliding.is-moving .kodza-share__tint {
transition: opacity 0.2s ease;
}
.kodza-share.is-moving .kodza-share__tip.is-on {
transition: opacity 0.15s ease;
}
/* The name of the option, above it, the same bubble as Tooltip: it glides from option to option
and its words roll */
.kodza-share__tip {
position: absolute;
bottom: calc(100% + 10px);
left: 0;
display: block;
padding: 7px 10px;
border-radius: 9px;
background: var(--kz-share-tip-bg);
color: var(--kz-share-tip-fg);
font-size: 13px;
font-weight: 500;
line-height: 1.35;
letter-spacing: 0;
white-space: nowrap;
pointer-events: none;
visibility: hidden;
opacity: 0;
transform: translateX(var(--kz-share-tip-x, 0px)) translateX(-50%) translateY(4px) scale(0.94);
transform-origin: 50% 100%;
transition:
opacity 0.15s ease,
transform 0.2s var(--kz-share-ease),
visibility 0s linear 0.2s;
}
.kodza-share__tip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
border: 5px solid transparent;
border-top-color: var(--kz-share-tip-bg);
transform: translateX(-50%);
}
.kodza-share__tip.is-on {
visibility: visible;
opacity: 1;
transform: translateX(var(--kz-share-tip-x, 0px)) translateX(-50%);
transition:
opacity 0.15s ease,
transform 0.25s var(--kz-share-ease),
visibility 0s;
}
.kodza-share__tip.is-on.is-gliding {
transition: transform 0.28s var(--kz-share-ease);
}
/* The words: the leaving ones step out of the flow, so the bubble takes the size of the new */
.kodza-share__words {
position: relative;
display: inline-block;
overflow: hidden;
vertical-align: top;
}
.kodza-share__words > span {
display: block;
}
.kodza-share__words > .is-leaving {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
}
.kodza-share__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.kodza-share__glyph,
.kodza-share.is-ready .kodza-share__option,
.kodza-share__option svg,
.kodza-share.is-ready .kodza-share__tint,
.kodza-share__label > span,
.kodza-share__tray,
.kodza-share__arrow,
.kodza-share__cross,
.kodza-share__link,
.kodza-share__check,
.kodza-share__tip {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* kodza-guard 29a071e57fbd — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-share__toggle):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-share__option):not(#_) {
outline: revert;
transition: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-share__tint:not(#_),
.kodza-share__tray:not(#_),
.kodza-share__arrow:not(#_),
.kodza-share__cross:not(#_),
.kodza-share__label:not(#_),
.kodza-share__options:not(#_),
.kodza-share__link:not(#_),
.kodza-share__check:not(#_),
.kodza-share__status:not(#_),
.kodza-share__tip:not(#_),
.kodza-share__words:not(#_),
.kodza-share__label span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-share__toggle:not(#_) {
text-decoration: revert;
transition: revert;
list-style: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
min-height: revert;
}
.kodza-share__glyph:not(#_),
.kodza-share__brand:not(#_),
.kodza-share__option svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-share__option:not(#_) {
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-share__tip:not(#_)::after {
box-sizing: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-share__toggle:not(#_) {
display: inline-flex;
flex: none;
align-items: center;
gap: 8px;
box-sizing: border-box;
height: var(--kz-share-option);
margin: 0;
padding: 0 18px 0 12px;
overflow: hidden;
border: 0;
border-radius: 999px;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
white-space: nowrap;
cursor: pointer;
}
.kodza-share.is-open .kodza-share__toggle:not(#_) {
width: var(--kz-share-option);
}
.kodza-share--icon .kodza-share__toggle:not(#_) {
width: var(--kz-share-option);
padding: 0 13px;
}
.kodza-share__toggle:focus-visible:not(#_),
.kodza-share__option:focus-visible:not(#_) {
outline: 2px solid var(--kz-share-fg);
outline-offset: -4px;
}
.kodza-share__toggle:disabled:not(#_) {
opacity: 0.45;
cursor: not-allowed;
}
.kodza-share__glyph:not(#_),
.kodza-share__option svg:not(#_) {
display: block;
}
.kodza-share__glyph:not(#_) {
transition: transform var(--kz-share-duration) var(--kz-share-unfold);
}
.kodza-share__option:not(#_) {
position: relative;
display: grid;
flex: none;
place-items: center;
width: var(--kz-share-option);
height: var(--kz-share-option);
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: inherit;
text-decoration: none;
cursor: pointer;
transform: translateX(calc((var(--kz-share-i, 0) + 1) * var(--kz-share-option) * -1));
}
.kodza-share.is-ready .kodza-share__option:not(#_) {
transition: transform var(--kz-share-duration) var(--kz-share-unfold);
}
.kodza-share.is-open .kodza-share__option:not(#_) {
transform: none;
}
.kodza-share.is-moving .kodza-share__option:not(#_) {
background: var(--kz-share-bg);
}
.kodza-share__option svg:not(#_) {
transition: opacity 0.2s ease 0.12s;
}
.kodza-share.is-open .kodza-share__option svg:not(#_) {
transition: opacity 0.3s ease 0.08s;
}
@media (prefers-reduced-motion: reduce) {
.kodza-share__glyph:not(#_),
.kodza-share__option svg:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-share.is-ready .kodza-share__option:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Share Button
// Opens and closes the options (click, Enter, Space; Escape, a click outside or Tab away close
// them), glides the tint and the label from option to option (pointer or arrows), fills each
// link with the page's URL and title ({url} and {title} in its href; data-url and data-title on
// the root to pick others), opens X and LinkedIn in a small window, and copies the link with a
// check and "Link copied" (data-copied for other words). Every share fires "kodza:share" with
// { network, url } on the root, for your analytics.
(() => {
const SELECTOR = '.kodza-share';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const RESET_AFTER = 1800;
async function writeText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Older browsers, pages served over http
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.append(area);
area.select();
const ok = document.execCommand('copy');
area.remove();
return ok;
}
}
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const toggle = root.querySelector('.kodza-share__toggle');
const list = root.querySelector('.kodza-share__options');
if (!toggle || !list) return;
const options = [...list.querySelectorAll('.kodza-share__option')];
const status = root.querySelector('.kodza-share__status');
const templates = new Map(options.filter((o) => o.matches('a[href]')).map((a) => [a, a.getAttribute('href')]));
options.forEach((option, i) => option.style.setProperty('--kz-share-i', i));
const tint = Object.assign(document.createElement('span'), { className: 'kodza-share__tint' });
tint.setAttribute('aria-hidden', 'true');
root.prepend(tint);
// The label above the options says what each one does (screen readers have the aria-label)
const tip = Object.assign(document.createElement('span'), { className: 'kodza-share__tip' });
tip.setAttribute('aria-hidden', 'true');
const words = Object.assign(document.createElement('span'), { className: 'kodza-share__words' });
tip.append(words);
root.append(tip);
const isOpen = () => root.classList.contains('is-open');
const nameOf = (option) => option.getAttribute('aria-label') || option.textContent.trim();
const timers = new Set();
const later = (fn, ms) => {
const id = setTimeout(() => {
timers.delete(id);
if (!signal.aborted) fn();
}, ms);
timers.add(id);
return id;
};
signal.addEventListener('abort', () => timers.forEach(clearTimeout));
// The page being shared, read when needed: an Ajax theme changes it without a page load
const page = () => ({
url: root.dataset.url || document.querySelector('link[rel="canonical"]')?.href || location.href,
title: root.dataset.title || document.querySelector('meta[property="og:title"]')?.content || document.title,
});
const fill = () => {
const { url, title } = page();
templates.forEach((template, link) => {
link.href = template.replace(/\{url\}/g, encodeURIComponent(url)).replace(/\{title\}/g, encodeURIComponent(title));
});
};
// Positions are read on screen, divided by the button's scale (a parent enlarged by
// transform), so they follow an option still sliding into place
const measure = (target) => {
const r = root.getBoundingClientRect();
const t = target.getBoundingClientRect();
const k = r.width / root.offsetWidth || 1;
return { left: (t.left - r.left) / k, width: t.width / k };
};
// The tint: placed at once the first time, then it glides (edges from its own box, inside
// the padding of the pill)
let tinted = null;
const placeTint = () => {
const { left, width } = measure(tinted);
root.style.setProperty('--kz-share-tint-l', `${left - tint.offsetLeft}px`);
root.style.setProperty('--kz-share-tint-w', `${width}px`);
};
const tintOn = (target) => {
const first = !tinted;
tinted = target;
placeTint();
if (first) {
root.classList.remove('is-gliding');
void getComputedStyle(tint).clipPath;
root.classList.add('is-gliding');
}
root.style.setProperty('--kz-share-tint-on', 1);
};
const tintOff = () => {
root.style.setProperty('--kz-share-tint-on', 0);
tinted = null;
};
// The label: above an option, it glides to the next one and its words slide along (the
// way Tooltip does it); on the same option, new words roll up
let tipped = null;
let copiedTimer = 0;
const write = (text, dir) => {
const prev = words.lastElementChild;
if (prev && prev.textContent === text) return;
const span = Object.assign(document.createElement('span'), { textContent: text });
words.append(span);
if (!prev) return;
if (!dir || reduceMotion) return prev.remove();
// The leaving words step out of the flow: the bubble takes the size of the new ones, and
// both slide right through its edge, the way the bubble goes
const before = prev.offsetWidth;
prev.classList.add('is-leaving');
const after = span.offsetWidth;
const timing = { duration: 260, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' };
const away = dir === 'up' ? 'translate(-50%, -100%)' : `translateX(calc(-50% - ${dir * ((before + after) / 2 + 2)}px))`;
const from = dir === 'up' ? 'translateY(100%)' : `translateX(${dir * (after + 2)}px)`;
const done = () => prev.remove();
prev.animate([{ transform: 'translateX(-50%)', opacity: 1 }, { transform: away, opacity: 0 }], { ...timing, fill: 'forwards' }).finished.then(done, done);
span.animate([{ transform: from, opacity: 0 }, { transform: 'none', opacity: 1 }], timing);
};
const placeTip = () => {
const { left, width } = measure(tipped);
tip.style.setProperty('--kz-share-tip-x', `${left + width / 2}px`);
};
const textOf = (option) => (option.classList.contains('is-copied') ? option.dataset.copied || 'Link copied' : nameOf(option));
const tipOn = (option, text = textOf(option)) => {
const wasOn = tip.classList.contains('is-on');
write(text, !wasOn ? 0 : tipped === option ? 'up' : Math.sign(options.indexOf(option) - options.indexOf(tipped)));
tipped = option;
if (wasOn) {
tip.classList.add('is-gliding');
placeTip();
} else {
// Appearing: straight above the option, it only rises into place
tip.style.transition = 'none';
placeTip();
void tip.offsetWidth;
tip.style.transition = '';
tip.classList.add('is-on');
}
};
const tipOff = () => {
tip.classList.remove('is-on', 'is-gliding');
tipped = null;
};
// Under the pointer: the tint, and the label for an option. While the button opens, the
// options slide in under a pointer that has not moved: they wait until it has settled
let pointer = 'mouse';
let moving = false;
// Where the pointer clicked to open it, until it moves away: what slid under it meanwhile is
// not a target, and a second click there closes it (it would otherwise share by mistake)
let rest = null;
const resting = (event) => rest && event.detail > 0 && Math.hypot(event.clientX - rest.x, event.clientY - rest.y) < 6;
const hover = (target) => {
if (pointer === 'touch' || ((moving || rest) && target !== toggle)) return;
tintOn(target);
if (target !== toggle) tipOn(target);
else if (tipped && !tipped.matches(':focus-visible')) tipOff();
};
const settle = () => {
if (rest) return;
const under =(isOpen() ? [toggle, ...options] : [toggle]).find((target) => target.matches(':hover'));
if (under && under !== tinted) hover(under);
};
// The button eases from one width to the other, Share closing down to its cross while the
// options open, on the timing of the options' slide (CSS): the last one rides the edge.
// While it moves, the tint and the label stay on what they sit on, frame by frame
const duration = () => {
const value = getComputedStyle(root).getPropertyValue('--kz-share-duration').trim();
return parseFloat(value) * (value.endsWith('ms') ? 1 : 1000) || 600;
};
let resizing = [];
let follow = 0;
const swap = (opened) => {
const from = [toggle.offsetWidth, list.offsetWidth];
resizing.forEach((animation) => animation.cancel());
cancelAnimationFrame(follow);
root.classList.toggle('is-open', opened);
toggle.setAttribute('aria-expanded', String(opened));
if (tinted && tinted !== toggle) tintOff();
if (!opened) tipOff();
if (reduceMotion) return settle();
const easing = getComputedStyle(root).getPropertyValue('--kz-share-unfold').trim() || 'ease-in-out';
resizing = [toggle, list].map((el, i) => el.animate([{ width: `${from[i]}px` }, { width: `${el.offsetWidth}px` }], { duration: duration(), easing }));
moving = true;
root.classList.add('is-moving');
const step = () => {
if (signal.aborted) return;
if (tinted) placeTint();
if (tipped) placeTip();
if (resizing.some((animation) => animation.playState === 'running')) {
follow = requestAnimationFrame(step);
return;
}
moving = false;
root.classList.remove('is-moving');
settle();
};
follow = requestAnimationFrame(step);
};
signal.addEventListener('abort', () => {
resizing.forEach((animation) => animation.cancel());
cancelAnimationFrame(follow);
});
const open = () => {
if (isOpen()) return;
fill();
swap(true);
};
const close = (refocus = false) => {
if (!isOpen()) return;
rest = null;
if (refocus || list.contains(document.activeElement)) toggle.focus({ preventScroll: true });
swap(false);
};
toggle.addEventListener('click', (event) => {
if (isOpen()) return close();
rest = event.detail > 0 ? { x: event.clientX, y: event.clientY } : null;
open();
}, { signal });
root.addEventListener('pointermove', (event) => {
if (!rest || Math.hypot(event.clientX - rest.x, event.clientY - rest.y) < 6) return;
rest = null;
if (!moving) settle();
}, { signal });
[toggle, ...options].forEach((target) => target.addEventListener('pointerenter', (event) => {
pointer = event.pointerType;
hover(target);
}, { signal }));
root.addEventListener('pointerleave', () => {
tintOff();
if (tipped && !tipped.matches(':focus-visible') && !tipped.classList.contains('is-copied')) tipOff();
}, { signal });
// Keyboard focus shows the label too, and the arrows go from option to option
options.forEach((option) => {
option.addEventListener('focus', () => option.matches(':focus-visible') && tipOn(option), { signal });
option.addEventListener('blur', () => tipped === option && !option.matches(':hover') && !option.classList.contains('is-copied') && tipOff(), { signal });
});
root.addEventListener('keydown', (event) => {
const stops = [toggle, ...options];
const index = stops.indexOf(document.activeElement);
const moves = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: stops.length - 1 };
if (!isOpen() || index < 0 || !(event.key in moves)) return;
event.preventDefault();
stops[Math.max(0, Math.min(stops.length - 1, moves[event.key]))].focus();
}, { signal });
// Escape closes it wherever the focus is: opened with a click, it stays on Share (Chrome)
// or on the page (Safari)
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !isOpen()) return;
event.preventDefault();
close(true);
}, { signal });
// Out of the button (a click elsewhere, Tab away), it closes. Checked once the focus has
// landed: a window that takes the focus (the share window) leaves it inside, and a click on
// an option does not count (Safari gives buttons no focus on click)
let pressing = false;
root.addEventListener('pointerdown', (event) => {
pointer = event.pointerType;
pressing = true;
}, { signal });
document.addEventListener('pointerup', () => later(() => (pressing = false), 0), { signal });
document.addEventListener('pointerdown', (event) => {
if (isOpen() && !root.contains(event.target)) close();
}, { signal });
root.addEventListener('focusout', () => {
later(() => isOpen() && !pressing && document.hasFocus() && !root.contains(document.activeElement) && close(), 0);
}, { signal });
const share = (network, url) => root.dispatchEvent(new CustomEvent('kodza:share', { detail: { network, url }, bubbles: true }));
options.forEach((option) => {
const network = option.dataset.share || 'link';
if (network === 'copy') {
option.addEventListener('click', async (event) => {
if (resting(event)) return close();
const { url } = page();
const ok = await writeText(url);
if (signal.aborted) return;
clearTimeout(copiedTimer);
option.classList.toggle('is-copied', ok);
const text = ok ? option.dataset.copied || 'Link copied' : "Couldn't copy. Use the address bar";
if (status) status.textContent = text;
if (pointer !== 'touch') tintOn(option);
tipOn(option, text);
if (ok) share('copy', url);
copiedTimer = later(() => {
option.classList.remove('is-copied');
if (status) status.textContent = '';
if (tipped !== option) return;
if (option.matches(':hover, :focus-visible')) tipOn(option);
else tipOff();
}, RESET_AFTER);
}, { signal });
return;
}
option.addEventListener('click', (event) => {
if (resting(event)) {
event.preventDefault();
return close();
}
fill();
share(network, page().url);
// X and LinkedIn in a small window over the page; a modified click (new tab) is left alone
if (!option.matches('a[target="_blank"]') || event.button || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const width = 600;
const height = 620;
const left = Math.round(screenX + (outerWidth - width) / 2);
const top = Math.round(screenY + (outerHeight - height) / 3);
const win = window.open(option.href, 'kodza-share', `popup,width=${width},height=${height},left=${left},top=${top}`);
if (!win) return; // blocked: the link opens a tab instead
win.opener = null;
event.preventDefault();
}, { signal });
});
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load.
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
instances.set(root, { controller, html: root.innerHTML, attrs: [...root.attributes].map((a) => [a.name, a.value]) });
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.controller.abort();
[...root.attributes].forEach((a) => root.removeAttribute(a.name));
instance.attrs.forEach(([name, value]) => root.setAttribute(name, value));
root.innerHTML = instance.html;
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Share Button, for React. From https://kodza.dev/components/share-button/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: share-button.css and share-button.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './share-button.css';
type Variant = "icon";
export type ShareButtonProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "variant" | "vars" | "onShare"> & {
/** Modifier class(es): .kodza-share--icon */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-share-bg"?: string | number;
/** default: #ffffff */
"--kz-share-fg"?: string | number;
/** default: transparent */
"--kz-share-border"?: string | number;
/** the hover, under one option — default: rgba(255, 255, 255, 0.14) */
"--kz-share-tint"?: string | number;
/** default: #0a0a0a */
"--kz-share-tip-bg"?: string | number;
/** default: #ffffff */
"--kz-share-tip-fg"?: string | number;
/** default: 52px */
"--kz-share-height"?: string | number;
/** default: 44px */
"--kz-share-option"?: string | number;
/** opening and closing — default: 0.6s */
"--kz-share-duration"?: string | number;
/** default: cubic-bezier(0.65, 0, 0.35, 1) */
"--kz-share-unfold"?: string | number;
/** the tint, the label, the small moves — default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-share-ease"?: string | number;
};
/** The text, plain: the script reads it and rebuilds the markup around it */
children?: string;
/** "kodza:share", fired by the script (bubbles from the root) */
onShare?: (event: CustomEvent) => void;
className?: string;
};
export function ShareButton({ variant, vars, children = "Share", onShare, className, style, ...rest }: ShareButtonProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-share", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-share--${v}`), className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, vars, children, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./share-button.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
useEffect(() => {
const root = ref.current;
if (!root || !onShare) return;
const listener = (event: Event) => onShare(event as CustomEvent);
root.addEventListener("kodza:share", listener);
return () => root.removeEventListener("kodza:share", listener);
}, [onShare, key]);
return (
<div key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<button className="kodza-share__toggle" type="button" aria-expanded="false">
<svg className="kodza-share__glyph" viewBox="0 0 24 24" aria-hidden="true">
<path className="kodza-share__tray" d="M5 11.5V18a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6.5" pathLength="1" />
<path className="kodza-share__arrow" d="M12 14.5V3.8M7.8 8 12 3.8 16.2 8" />
<path className="kodza-share__cross" d="M6.8 6.8l10.4 10.4" pathLength="1" />
<path className="kodza-share__cross" d="M17.2 6.8 6.8 17.2" pathLength="1" />
</svg>
{' '}
<span className="kodza-share__label">
<span>{children}</span>
</span>
</button>
<div className="kodza-share__options" role="group" aria-label="Share this page">
<button className="kodza-share__option" type="button" data-share="copy" aria-label="Copy link">
<svg viewBox="0 0 24 24" aria-hidden="true">
<g className="kodza-share__link">
<path d="M9.6 14.4l4.8-4.8" />
<path d="M11.2 6.6l1.3-1.3a4 4 0 0 1 5.7 5.7l-1.3 1.3" />
<path d="M12.8 17.4l-1.3 1.3a4 4 0 0 1-5.7-5.7l1.3-1.3" />
</g>
<path className="kodza-share__check" d="M5.5 12.5l4.2 4.2 8.8-9.2" pathLength="1" />
</svg>
</button>
{' '}
<a className="kodza-share__option" data-share="x" href={"https://x.com/intent/post?url={url}&text={title}"} target="_blank" rel="noopener" aria-label="Post on X">
<svg className="kodza-share__brand" viewBox="0 0 24 24" aria-hidden="true">
<path d="M17.7 3h3l-6.6 7.6L22 21h-6.1l-4.8-6.3L5.6 21h-3l7.1-8.1L2.2 3h6.2l4.4 5.8L17.7 3Zm-1 16.2h1.7L7.4 4.7H5.6l11.1 14.5Z" />
</svg>
</a>
{' '}
<a className="kodza-share__option" data-share="linkedin" href={"https://www.linkedin.com/sharing/share-offsite/?url={url}"} target="_blank" rel="noopener" aria-label="Share on LinkedIn">
<svg className="kodza-share__brand" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="5" cy="5.2" r="2.2" />
<path d="M3.2 9h3.6v11.5H3.2zM9.4 9h3.45v1.6c.55-1 1.9-1.95 3.85-1.95 3.65 0 4.3 2.4 4.3 5.5v6.35h-3.6v-5.6c0-1.35-.05-3.05-1.85-3.05-1.85 0-2.55 1.4-2.55 2.95v5.7H9.4z" />
</svg>
</a>
{' '}
<a className="kodza-share__option" data-share="email" href={"mailto:?subject={title}&body={url}"} aria-label="Send by email">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3.5" y="5.5" width="17" height="13" rx="2.5" />
<path d="M4.5 7.5l7.5 5.5 7.5-5.5" />
</svg>
</a>
</div>
<span className="kodza-share__status" role="status" />
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.