Share Button
Press 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.
View codeThe 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.
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 Split Button to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-split" role="group" aria-label="Merge">
<button class="kodza-split__main" type="button" value="merge"><span class="kodza-split__label">Merge pull request</span></button>
<button class="kodza-split__toggle" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="More ways to merge">
<svg class="kodza-split__chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6.5 8 10.5l4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="kodza-split__menu" role="menu" aria-label="Ways to merge">
<button class="kodza-split__item" type="button" role="menuitemradio" aria-checked="true" value="merge">Merge pull request <span class="kodza-split__hint">every commit</span></button>
<button class="kodza-split__item" type="button" role="menuitemradio" aria-checked="false" value="squash">Squash & merge <span class="kodza-split__hint">one tidy commit</span></button>
<button class="kodza-split__item" type="button" role="menuitemradio" aria-checked="false" value="rebase">Rebase <span class="kodza-split__hint">history, rewritten</span></button>
</div>
</div>
<div class="kodza-split kodza-split--outline" role="group" aria-label="Save">
<button class="kodza-split__main" type="button"><span class="kodza-split__label">Save draft</span></button>
<button class="kodza-split__toggle" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="More save options">
<svg class="kodza-split__chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6.5 8 10.5l4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="kodza-split__menu" role="menu" aria-label="Save options">
<button class="kodza-split__item" type="button" role="menuitem">Save and publish <kbd class="kodza-split__hint">⌘↵</kbd></button>
<button class="kodza-split__item" type="button" role="menuitem">Save as template</button>
<button class="kodza-split__item" type="button" role="menuitem">Schedule for Monday</button>
<div class="kodza-split__separator" role="separator"></div>
<button class="kodza-split__item" type="button" role="menuitem" aria-disabled="true">Publish on a Friday <span class="kodza-split__hint">Nope</span></button>
</div>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Split Button
The main action on the left, an arrow on the right that unrolls the other options in a menu.
One tint slides from half to half under the pointer and stays on the arrow while the menu is
open. Pick a way of doing it (menuitemradio) and the main label rolls to it, the button easing
to its new width.
Everything is tuned with the --kz-split-* variables. */
.kodza-split {
--kz-split-bg: #0a0a0a;
--kz-split-fg: #ffffff;
--kz-split-border: transparent;
--kz-split-divider: rgba(255, 255, 255, 0.22);
--kz-split-tint: rgba(255, 255, 255, 0.14); /* the hover, under one half */
--kz-split-height: 52px;
--kz-split-pad-x: 24px;
--kz-split-toggle: 50px; /* the arrow's half */
--kz-split-menu-bg: #ffffff;
--kz-split-menu-fg: #0a0a0a;
--kz-split-menu-muted: rgba(10, 10, 10, 0.58);
--kz-split-menu-border: rgba(10, 10, 10, 0.1);
--kz-split-menu-soft: rgba(10, 10, 10, 0.06);
--kz-split-menu-shadow: 0 18px 40px -16px rgba(10, 10, 10, 0.3);
--kz-split-menu-width: 240px;
--kz-split-duration: 0.35s;
--kz-split-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: inline-flex;
align-items: stretch;
box-sizing: border-box;
height: var(--kz-split-height);
border-radius: 999px;
background: var(--kz-split-bg);
box-shadow: inset 0 0 0 1.5px var(--kz-split-border);
color: var(--kz-split-fg);
vertical-align: middle;
-webkit-tap-highlight-color: transparent;
/* A number, not auto, so it can wait for the menu to roll up before dropping back */
z-index: 0;
transition: z-index 0s linear var(--kz-split-duration);
}
/* Open, the button rises above what follows it (its menu included) */
.kodza-split.is-open {
z-index: 20;
transition-delay: 0s;
}
/* The second look: a line, no fill */
.kodza-split--outline {
--kz-split-bg: transparent;
--kz-split-fg: #0a0a0a;
--kz-split-border: rgba(10, 10, 10, 0.9);
--kz-split-divider: rgba(10, 10, 10, 0.2);
--kz-split-tint: rgba(10, 10, 10, 0.07);
}
.kodza-split__main,
.kodza-split__toggle {
position: relative;
display: inline-flex;
align-items: center;
margin: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
white-space: nowrap;
cursor: pointer;
}
.kodza-split__main {
padding: 0 18px 0 var(--kz-split-pad-x);
border-radius: 999px 0 0 999px;
}
.kodza-split__toggle {
justify-content: center;
width: var(--kz-split-toggle);
padding: 0 4px 0 0;
border-radius: 0 999px 999px 0;
}
/* The line between the two halves */
.kodza-split__toggle::before {
content: "";
position: absolute;
top: 15px;
bottom: 15px;
left: 0;
width: 1px;
background: var(--kz-split-divider);
}
/* Keyboard focus: a ring cut like the tint, round at both ends of its half with 3px of air at
the line, not a half-pill whose corners at the divider are square */
.kodza-split__main:focus-visible,
.kodza-split__toggle:focus-visible {
outline: none;
}
.kodza-split__main:focus-visible::after,
.kodza-split__toggle:focus-visible::after {
content: "";
position: absolute;
inset: 4px;
border: 2px solid var(--kz-split-fg);
border-radius: 999px;
pointer-events: none;
}
.kodza-split__main:focus-visible::after {
right: 3px;
}
.kodza-split__toggle:focus-visible::after {
left: 3px;
}
.kodza-split__main:disabled,
.kodza-split__toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* The label: one word on top of the other, so a new one can roll in */
.kodza-split__label {
display: inline-grid;
overflow: hidden;
text-align: left;
transition: transform 0.18s ease;
}
.kodza-split__label > span {
grid-area: 1 / 1;
justify-self: start; /* each word keeps its own width: the button can ease to it */
}
.kodza-split__chevron {
width: 16px;
height: 16px;
transition: transform var(--kz-split-duration) var(--kz-split-ease);
}
.kodza-split__toggle[aria-expanded="true"] .kodza-split__chevron {
transform: rotate(180deg);
}
/* Pressed: the half you press gives a little */
.kodza-split__main:active:not(:disabled) .kodza-split__label {
transform: scale(0.97);
}
/* One tint for both halves, cut out by clip-path around the one under the pointer (the
script sends the edges), so it slides from half to half like the family's indicator */
.kodza-split__tint {
position: absolute;
inset: 4px;
z-index: -1;
background: var(--kz-split-tint);
opacity: var(--kz-split-tint-on, 0);
clip-path: inset(0 var(--kz-split-tint-r, 100%) 0 var(--kz-split-tint-l, 0) round 999px);
pointer-events: none;
}
.kodza-split.is-ready.is-gliding .kodza-split__tint {
transition:
clip-path 0.3s var(--kz-split-ease),
opacity 0.2s ease;
}
.kodza-split.is-ready:not(.is-gliding) .kodza-split__tint {
transition: opacity 0.2s ease;
}
/* The menu of the navigation family, unrolled from its top edge under the arrow */
.kodza-split__menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: max(100%, var(--kz-split-menu-width));
max-width: calc(100vw - 16px);
padding: 6px;
border-radius: 14px;
background: var(--kz-split-menu-bg);
box-shadow: 0 0 0 1px var(--kz-split-menu-border), var(--kz-split-menu-shadow);
color: var(--kz-split-menu-fg);
visibility: hidden;
opacity: 0;
clip-path: inset(0 0 100% 0 round 14px);
transform: translateY(-4px);
transition:
clip-path var(--kz-split-duration) var(--kz-split-ease),
opacity 0.2s ease,
transform var(--kz-split-duration) var(--kz-split-ease),
visibility 0s linear var(--kz-split-duration);
}
.kodza-split.is-open .kodza-split__menu {
visibility: visible;
opacity: 1;
clip-path: inset(-40px -40px -60px -40px round 14px);
transform: none;
transition-delay: 0s;
}
/* Not enough room below: it opens upwards; too close to the left edge: it lines up on the left */
.kodza-split.is-above .kodza-split__menu {
top: auto;
bottom: calc(100% + 8px);
clip-path: inset(100% 0 0 0 round 14px);
transform: translateY(4px);
}
.kodza-split.is-above.is-open .kodza-split__menu {
clip-path: inset(-60px -40px -40px -40px round 14px);
transform: none;
}
.kodza-split.is-start .kodza-split__menu {
right: auto;
left: var(--kz-split-shift, 0px);
}
/* One highlight for the whole menu, sent to the focused item */
.kodza-split__highlight {
position: absolute;
top: 0;
right: 6px;
left: 6px;
height: var(--kz-hl-h, 36px);
border-radius: 8px;
background: var(--kz-split-menu-soft);
opacity: var(--kz-hl-on, 0);
transform: translateY(var(--kz-hl-y, 0));
pointer-events: none;
}
.kodza-split.is-open .kodza-split__highlight {
transition: transform 0.22s var(--kz-split-ease), opacity 0.15s ease;
}
.kodza-split__item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
height: 36px;
margin: 0;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
font-size: 14px;
font-weight: 500;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.kodza-split__item:focus {
outline: none;
}
.kodza-split__item[aria-disabled="true"] {
color: var(--kz-split-menu-muted);
cursor: not-allowed;
}
/* Ways of doing the main action keep room for the check of the current one */
.kodza-split__item[role="menuitemradio"] {
padding-left: 32px;
}
.kodza-split__item[aria-checked="true"]::before {
content: "";
position: absolute;
left: 10px;
width: 14px;
height: 14px;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.5l3.2 3L13 4.5' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.5l3.2 3L13 4.5' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
.kodza-split__hint {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
font-weight: 400;
color: var(--kz-split-menu-muted);
overflow: hidden;
text-overflow: ellipsis;
}
.kodza-split__separator {
height: 1px;
margin: 6px -6px;
background: var(--kz-split-menu-border);
}
/* The picked item blinks once, like a desktop menu */
.kodza-split.is-picking .kodza-split__highlight {
animation: kodza-split-blink 0.16s steps(2, jump-none) 2;
}
@keyframes kodza-split-blink {
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-split__menu,
.kodza-split.is-open .kodza-split__highlight,
.kodza-split.is-ready .kodza-split__tint,
.kodza-split__chevron,
.kodza-split__label {
transition-duration: 0.01ms !important;
}
.kodza-split.is-picking .kodza-split__highlight {
animation: none;
}
}
/* kodza-guard 5f757dbe8b9a — 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-split__main):not(#_),
:where(.kodza-split__toggle):not(#_),
:where(.kodza-split__item):not(#_) {
outline: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-split__tint:not(#_),
.kodza-split__label:not(#_),
.kodza-split__highlight:not(#_),
.kodza-split__separator:not(#_),
.kodza-split__label span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-split__main:not(#_),
.kodza-split__toggle:not(#_) {
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
outline-offset: 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-split__chevron: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-split__menu:not(#_) {
outline: revert;
outline-offset: revert;
}
.kodza-split__item:not(#_) {
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
min-height: revert;
}
.kodza-split__hint:not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: 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;
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-split__chevron:not(#_):where(:not([hidden])),
.kodza-split__hint:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-split__chevron:not(#_):where([hidden]),
.kodza-split__hint:not(#_):where([hidden]) {
display: none;
}
.kodza-split__toggle:not(#_)::before,
.kodza-split__main:not(#_)::after,
.kodza-split__toggle:not(#_)::after,
.kodza-split__item:not(#_)::before {
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-split__main:not(#_),
.kodza-split__toggle:not(#_) {
position: relative;
display: inline-flex;
align-items: center;
margin: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
white-space: nowrap;
cursor: pointer;
}
.kodza-split__main:not(#_) {
padding: 0 18px 0 var(--kz-split-pad-x);
border-radius: 999px 0 0 999px;
}
.kodza-split__toggle:not(#_) {
justify-content: center;
width: var(--kz-split-toggle);
padding: 0 4px 0 0;
border-radius: 0 999px 999px 0;
}
.kodza-split__toggle:not(#_)::before {
content: "";
position: absolute;
top: 15px;
bottom: 15px;
left: 0;
width: 1px;
background: var(--kz-split-divider);
}
.kodza-split__main:focus-visible:not(#_),
.kodza-split__toggle:focus-visible:not(#_) {
outline: none;
}
.kodza-split__main:focus-visible:not(#_)::after,
.kodza-split__toggle:focus-visible:not(#_)::after {
content: "";
position: absolute;
inset: 4px;
border: 2px solid var(--kz-split-fg);
border-radius: 999px;
pointer-events: none;
}
.kodza-split__main:focus-visible:not(#_)::after {
right: 3px;
}
.kodza-split__toggle:focus-visible:not(#_)::after {
left: 3px;
}
.kodza-split__main:disabled:not(#_),
.kodza-split__toggle:disabled:not(#_) {
opacity: 0.45;
cursor: not-allowed;
}
.kodza-split__chevron:not(#_) {
transition: transform var(--kz-split-duration) var(--kz-split-ease);
}
.kodza-split__menu:not(#_) {
box-shadow: 0 0 0 1px var(--kz-split-menu-border), var(--kz-split-menu-shadow);
}
.kodza-split__item:not(#_) {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
height: 36px;
margin: 0;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
font-size: 14px;
font-weight: 500;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.kodza-split__item:focus:not(#_) {
outline: none;
}
.kodza-split__item[aria-disabled="true"]:not(#_) {
color: var(--kz-split-menu-muted);
cursor: not-allowed;
}
.kodza-split__item[role="menuitemradio"]:not(#_) {
padding-left: 32px;
}
.kodza-split__item[aria-checked="true"]:not(#_)::before {
content: "";
position: absolute;
left: 10px;
width: 14px;
height: 14px;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.5l3.2 3L13 4.5' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.5l3.2 3L13 4.5' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
.kodza-split__hint:where(kbd):not(#_) {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
font-weight: 400;
color: var(--kz-split-menu-muted);
}
@media (prefers-reduced-motion: reduce) {
.kodza-split__chevron:not(#_) {
transition-duration: 0.01ms !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Split Button
// Slides the hover tint from half to half, opens and closes the menu of the arrow (click,
// Enter, Space, arrows), moves the focus and the highlight from item to item (arrows, Home,
// End, first letter), and closes on Escape, Tab or a click outside. Picking an item fires
// "kodza:select" with { item, value } on the split button; a way of doing the main action
// (menuitemradio) also becomes the main button's label and value, so its own click does that.
(() => {
const SELECTOR = '.kodza-split';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const main = root.querySelector('.kodza-split__main');
const toggle = root.querySelector('.kodza-split__toggle');
const menu = root.querySelector('.kodza-split__menu');
if (!main || !toggle || !menu) return;
const label = main.querySelector('.kodza-split__label');
const items = [...menu.querySelectorAll('.kodza-split__item')];
const tint = document.createElement('span');
tint.className = 'kodza-split__tint';
tint.setAttribute('aria-hidden', 'true');
root.prepend(tint);
const highlight = document.createElement('span');
highlight.className = 'kodza-split__highlight';
highlight.setAttribute('aria-hidden', 'true');
menu.prepend(highlight);
items.forEach((item) => (item.tabIndex = -1));
if (label) label.replaceChildren(Object.assign(document.createElement('span'), { textContent: label.textContent.trim() }));
const isOpen = () => root.classList.contains('is-open');
const valueOf = (item) => item.value || item.firstChild.textContent.trim();
// The tint: placed at once the first time, then it glides from half to half
let tinted = null;
// (edges measured from the tint's own box, 4px inside the button; 3px of air at the line)
const place = (half) => {
const inner = root.clientWidth - 8;
const left = half === main ? 0 : toggle.offsetLeft + 3 - 4;
const right = half === main ? inner - (main.offsetWidth - 3 - 4) : 0;
root.style.setProperty('--kz-split-tint-l', `${left}px`);
root.style.setProperty('--kz-split-tint-r', `${right}px`);
};
const tintOn = (half) => {
if (half.disabled) return tintOff();
if (!tinted) {
root.classList.remove('is-gliding');
place(half);
void getComputedStyle(tint).clipPath;
root.classList.add('is-gliding');
} else {
place(half);
}
root.style.setProperty('--kz-split-tint-on', 1);
tinted = half;
};
const tintOff = () => {
root.style.setProperty('--kz-split-tint-on', 0);
tinted = null;
};
// Its edges follow the button when its width changes (a new label, a narrower column)
if (window.ResizeObserver) {
const watch = new ResizeObserver(() => tinted && place(tinted));
watch.observe(root);
signal.addEventListener('abort', () => watch.disconnect());
}
// A new way of doing it rolls into the main button: the old words leave upwards, the new
// ones rise, and the button eases to its new width instead of jumping
let rolls = 0;
const roll = (text) => {
const prev = label.lastElementChild;
if (!prev || prev.textContent === text) return;
const from = label.offsetWidth;
const span = Object.assign(document.createElement('span'), { textContent: text });
label.append(span);
if (reduceMotion) return prev.remove();
const to = span.offsetWidth;
const timing = { duration: 450, delay: 280, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' };
prev.setAttribute('aria-hidden', 'true');
// (held at its end until the old words are gone: the grid would be as wide as both for a frame.
// Wider, it opens ahead of the new words so they are not cut on the way in; narrower, it
// eases in and out so the old ones are not cut before they have left)
const fitTiming = to > from
? { duration: 300, delay: 140, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }
: { ...timing, easing: 'cubic-bezier(0.65, 0, 0.35, 1)' };
const fit = label.animate([{ width: `${from}px` }, { width: `${to}px` }], { ...fitTiming, fill: 'both' });
// The tint stays tied to its half while the arrow moves: no glide of its own meanwhile
root.classList.remove('is-gliding');
const id = ++rolls;
const done = () => {
prev.remove();
fit.cancel();
if (id === rolls && !signal.aborted) root.classList.add('is-gliding');
};
prev.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' }).finished.then(done, done);
span.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], { ...timing, fill: 'backwards' });
};
const light = (item) => {
if (!item) {
menu.style.setProperty('--kz-hl-on', 0);
return;
}
menu.style.setProperty('--kz-hl-y', `${item.offsetTop}px`);
menu.style.setProperty('--kz-hl-h', `${item.offsetHeight}px`);
menu.style.setProperty('--kz-hl-on', 1);
};
const focusItem = (item) => {
if (!item) return;
item.focus({ preventScroll: true });
light(item);
};
const open = (which = 'none') => {
if (!isOpen()) {
const box = root.getBoundingClientRect();
const height = menu.offsetHeight + 8;
const above = box.bottom + height > innerHeight - 8 && box.top - height > 8;
// A closed menu that changes side gets there at once, so it always unrolls from the button
if (above !== root.classList.contains('is-above')) {
menu.style.transition = 'none';
root.classList.toggle('is-above', above);
void menu.offsetWidth;
menu.style.transition = '';
}
// Under the arrow, its right edges lined up; no room on the left: from the button's left
// edge, moved back in if it would cross the right edge of the screen. Screen pixels
// divided by the button's scale (a parent enlarged by transform)
const scale = box.width / root.offsetWidth || 1;
const width = menu.offsetWidth * scale;
const start = box.right - width < 8;
root.classList.toggle('is-start', start);
// (the page's width, not innerWidth: on a phone it grows with whatever overflows)
const over = start ? Math.max(0, box.left + width - (document.documentElement.clientWidth - 8)) : 0;
root.style.setProperty('--kz-split-shift', `${-Math.min(over, Math.max(0, box.left - 8)) / scale}px`);
root.classList.add('is-open');
toggle.setAttribute('aria-expanded', 'true');
tintOn(toggle);
}
const checked = items.find((item) => item.getAttribute('aria-checked') === 'true');
if (which === 'first') focusItem(checked || items[0]);
else if (which === 'last') focusItem(items[items.length - 1]);
else light(null);
};
const close = (refocus = false) => {
if (!isOpen()) return;
root.classList.remove('is-open', 'is-picking');
toggle.setAttribute('aria-expanded', 'false');
light(null);
if (refocus) toggle.focus();
// The tint goes to the half under the pointer, or leaves with it
const under = [main, toggle].find((half) => half.matches(':hover'));
if (under) tintOn(under);
else if (!root.matches(':hover')) tintOff();
};
const pick = (item) => {
if (item.getAttribute('aria-disabled') === 'true') return;
const value = valueOf(item);
if (item.getAttribute('role') === 'menuitemradio') {
items.forEach((other) => other.getAttribute('role') === 'menuitemradio' && other.setAttribute('aria-checked', String(other === item)));
if (label) roll(item.firstChild.textContent.trim());
main.value = value;
}
root.dispatchEvent(new CustomEvent('kodza:select', { detail: { item, value }, bubbles: true }));
light(item);
root.classList.add('is-picking');
setTimeout(() => !signal.aborted && close(true), reduceMotion ? 0 : 320);
};
[main, toggle].forEach((half) => half.addEventListener('pointerenter', () => tintOn(isOpen() ? toggle : half), { signal }));
root.addEventListener('pointerleave', () => !isOpen() && tintOff(), { signal });
menu.addEventListener('pointerenter', () => tintOn(toggle), { signal });
toggle.addEventListener('click', () => (isOpen() ? close() : open()), { signal });
toggle.addEventListener('keydown', (event) => {
if (['ArrowDown', 'Enter', ' '].includes(event.key)) {
event.preventDefault();
open('first');
} else if (event.key === 'ArrowUp') {
event.preventDefault();
open('last');
}
}, { signal });
items.forEach((item) => {
item.addEventListener('click', () => pick(item), { signal });
item.addEventListener('pointermove', () => document.activeElement !== item && focusItem(item), { signal });
});
menu.addEventListener('pointerleave', () => {
if (menu.contains(document.activeElement)) menu.focus({ preventScroll: true });
light(null);
}, { signal });
menu.addEventListener('keydown', (event) => {
const index = items.indexOf(document.activeElement);
const moves = { ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: items.length - 1 };
if (event.key in moves) {
event.preventDefault();
focusItem(items[(moves[event.key] + items.length) % items.length]);
} else if (event.key === 'Escape') {
event.preventDefault();
close(true);
} else if (event.key === 'Tab') {
close();
} else if (event.key.length === 1 && /\S/.test(event.key)) {
// Type a letter: jump to the next item that starts with it
const letter = event.key.toLowerCase();
const order = [...items.slice(index + 1), ...items.slice(0, index + 1)];
focusItem(order.find((item) => item.textContent.trim().toLowerCase().startsWith(letter)));
}
}, { signal });
// (the main half is inside the root too: doing the action closes the menu as well)
document.addEventListener('pointerdown', (event) => {
if (isOpen() && (!root.contains(event.target) || main.contains(event.target))) close();
}, { signal });
// Escape closes it wherever the focus is: opened with a click, it stays on the arrow
// (Chrome) or on the page (Safari), not in the menu
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !isOpen()) return;
event.preventDefault();
close(true);
}, { signal });
menu.tabIndex = -1;
}
// 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 — Split Button, for React. From https://kodza.dev/components/split-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: split-button.css and split-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 './split-button.css';
type Variant = "outline";
export type SplitButtonProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "variant" | "vars" | "onSelect"> & {
/** Modifier class(es): .kodza-split--outline */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-split-bg"?: string | number;
/** default: #ffffff */
"--kz-split-fg"?: string | number;
/** default: transparent */
"--kz-split-border"?: string | number;
/** default: rgba(255, 255, 255, 0.22) */
"--kz-split-divider"?: string | number;
/** the hover, under one half — default: rgba(255, 255, 255, 0.14) */
"--kz-split-tint"?: string | number;
/** default: 52px */
"--kz-split-height"?: string | number;
/** default: 24px */
"--kz-split-pad-x"?: string | number;
/** the arrow's half — default: 50px */
"--kz-split-toggle"?: string | number;
/** default: #ffffff */
"--kz-split-menu-bg"?: string | number;
/** default: #0a0a0a */
"--kz-split-menu-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.58) */
"--kz-split-menu-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-split-menu-border"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-split-menu-soft"?: string | number;
/** default: 0 18px 40px -16px rgba(10, 10, 10, 0.3) */
"--kz-split-menu-shadow"?: string | number;
/** default: 240px */
"--kz-split-menu-width"?: string | number;
/** default: 0.35s */
"--kz-split-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-split-ease"?: string | number;
};
/** "kodza:select", fired by the script (bubbles from the root) */
onSelect?: (event: CustomEvent) => void;
className?: string;
};
export function SplitButton({ variant, vars, onSelect, className, style, ...rest }: SplitButtonProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-split", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-split--${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, 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('./split-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 || !onSelect) return;
const listener = (event: Event) => onSelect(event as CustomEvent);
root.addEventListener("kodza:select", listener);
return () => root.removeEventListener("kodza:select", listener);
}, [onSelect, key]);
return (
<div role="group" aria-label="Merge" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<button className="kodza-split__main" type="button" value="merge">
<span className="kodza-split__label">Merge pull request</span>
</button>
{' '}
<button className="kodza-split__toggle" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="More ways to merge">
<svg className="kodza-split__chevron" viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 6.5 8 10.5l4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className="kodza-split__menu" role="menu" aria-label="Ways to merge">
<button className="kodza-split__item" type="button" role="menuitemradio" aria-checked="true" value="merge">Merge pull request <span className="kodza-split__hint">every commit</span></button>
{' '}
<button className="kodza-split__item" type="button" role="menuitemradio" aria-checked="false" value="squash">{"Squash & merge "}<span className="kodza-split__hint">one tidy commit</span></button>
{' '}
<button className="kodza-split__item" type="button" role="menuitemradio" aria-checked="false" value="rebase">Rebase <span className="kodza-split__hint">history, rewritten</span></button>
</div>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.