Social Share Buttons: 9 Copy-Paste UI Patterns
Social share buttons are the last door a page leaves through, and the one tap a fake demo gives away.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Inline share row
- 02 Floating side share rail
- 03 Copy-link toast
- 04 Share-count count-up
- 05 Expanding icon share button
- 06 Mobile share sheet
- 07 Sticky bottom share bar
- 08 QR code share popup
- 09 Email and embed share box
The nine are ordered by when a visitor meets them, not by what is trendy. They start with the inline row found at the end of an article (01), move to the rail that rides beside the text while they read (02) and the toast that answers the moment they press copy (03). After the share-count badges that show proof (04) come the two ways to save space — an icon that fans out of a single button (05) and a bar pinned to the bottom (07) — while phones get the sheet that rises from below (06). The last two close the loop: a QR popup that bridges offline paper (08) and a box that lets the sender pick mail or embed (09). All nine share the same ink #15131a, cream #fdf8f0, and sky #7fd4f5, so mixing two or three never drifts out of palette.
01Inline share row
A label and four round 44px icon buttons in one row at the bottom of the card — the shape everyone builds first. On hover and focus a button lifts 4px while a circle laid over it fills from scale(.72) to scale(1), and 44px is the minimum fingertip target. A pressed button stays pressed via aria-pressed, showing what was picked.
/* The ring is a stacked circle, not a border — scaling it never relayouts */
.ir__ring {
position: absolute;
inset: 0;
border: 2px solid $color;
border-radius: 50%;
transform: scale(.72);
opacity: 0;
transition: transform $dur-quick $easing, opacity $dur-quick $easing;
}
.ir__btn:focus-visible .ir__ring {
transform: scale(1);
opacity: 1;
}
.ir__btn[aria-pressed="true"] {
background: $color;
color: $sh-paper;
}
02Floating side share rail
On magazine-style sites with long articles, the rail sits vertically beside the text and follows the scroll with position: sticky. Buttons keep their labels hidden until hover or focus unfolds them sideways — the unfolding reveals a right side kept cut off by clip-path: inset(0 100px 0 0 round 999px), so layout never recalculates. After the 100px cut, 36px stay visible while the label only starts at 40px (12px padding, 20px icon, 8px gap), so no letter peeks while collapsed.
/* Reserve the full 136px width, then cut 100px off the right */
.sr__link {
width: 136px;
height: 44px;
clip-path: inset(0 100px 0 0 round $r-pill);
transition: clip-path $dur-base $easing;
}
.sr__link:hover,
.sr__link:focus-visible {
clip-path: inset(0 0 0 0 round $r-pill);
}
03Copy-link toast
Half of sharing is copying, so pressing the button actually writes the URL through navigator.clipboard.writeText and a toast rising from below reports the result. When the clipboard is missing it selects the input, and either way the toast says "copy it yourself" with a cross mark rather than a check — showing a check for a failure would be a lie. One role="status" region reads the outcome.
function show(msg, failed) {
text.textContent = msg;
root.classList.toggle('is-bad', !!failed); // blocked: cross mark, not a check
root.classList.add('is-done');
}
navigator.clipboard.writeText(url).then(
function () { show('주소를 복사했습니다', false); },
function () { show('복사가 막혔습니다. 직접 선택해 주세요', true); }
);
04Share-count count-up
Badges showing how often a page traveled are proof, yet rolling the number from zero up to the goal needs no JavaScript counter — a custom property registered with @property and a CSS counter do it in place. Using it without registration leaves it untyped, un-interpolated, and good for one mid-flight blip; that is the crux of this pattern. Because it is registered with inherits: false, the value must sit on the badge itself.
@property --shareCount {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
.cb__badge {
counter-reset: shareCount var(--shareCount);
}
.cb__badge::after { content: counter(shareCount); }
.cb__badge--a { --shareCount: 128; } /* final value outside .is-demo */
05Expanding icon share button
On a one-page portfolio with a narrow header, you keep a single share button, and pressing it fans four platform buttons out sideways with a stagger. Collapsing is not shrinking the width to zero but cutting the buttons fully off with clip-path, so their places stay reserved and neighboring text never gets pushed. While collapsed, the buttons drop out of the Tab order with tabIndex -1, and Escape folds them back and returns focus to the share button.
.ex__item {
transform: translateX(-16px);
clip-path: inset(0 100% 0 0 round 50%);
transition: clip-path $dur-base $easing, transform $dur-base $easing;
}
.ex.is-open .ex__item {
transform: translateX(0);
clip-path: inset(0 0 0 0 round 50%);
}
.ex.is-open .ex__item:nth-child(2) { transition-delay: 60ms; }
06Mobile share sheet
On phones, the sheet rising from the bottom is the familiar shape: pressing share raises a role="dialog" sheet, dims the backdrop, and the four cells surface a beat after the sheet. The backbone is the focus trap. Opening moves focus to the first button, Tab wraps from last back to first, Escape and a backdrop click close it, and closing returns focus to the share button.
// Focus trap — while the sheet is open, Tab stays inside it
sheet.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { setOpen(false); return; }
if (e.key !== 'Tab') return;
var list = focusables(); var last = list.length - 1;
var at = list.indexOf(document.activeElement);
if (e.shiftKey && at <= 0) { e.preventDefault(); list[last].focus({ preventScroll: true }); }
else if (!e.shiftKey && at === last) { e.preventDefault(); list[0].focus({ preventScroll: true }); }
});
07Sticky bottom share bar
To keep the reading flow intact, the bar pins to the bottom of the document with position: sticky; bottom: 0, and pressing the handle or swiping up more than 12px unfolds five icons. The unfolded height is not a fixed pixel but a grid-template-rows: 0fr → 1fr track — height: auto has no intermediate value while 0fr and 1fr do. The inner wrapper needs min-height: 0 or the track never truly collapses.
.sb__panel {
display: grid;
grid-template-rows: 0fr; /* collapsed */
overflow: hidden;
transition: grid-template-rows $dur-base $easing;
}
.sb__inner { min-height: 0; } /* the one line that makes 0fr fold */
.sb.is-open .sb__panel { grid-template-rows: 1fr; }
08QR code share popup
Where hands cannot reach — cards, booths, signs — the screen passes the address to a camera, and this popup's QR is not an image file but a 29×29 module matrix of black and white grid cells, so it never blurs when enlarged and cannot be tinted by mistake. Reading the actually rendered frame back through a camera library returned https://godrichstory.com. The scan line sweeping the code exists only in the preview.
// A 29×29 module matrix — black-cell coordinates, not an image.
// The full 29 rows ship in the zip's original file.
var rows = [
"00000000000000000000000000000",
"00111111101100010010111111100",
"00100000100000010000100000100"
];
rows.forEach(function (line) {
line.split('').forEach(function (bit) {
var cell = document.createElement('span');
cell.className = bit === '1' ? 'qp__cell is-on' : 'qp__cell';
cells.appendChild(cell);
});
});
grid.appendChild(cells);
09Email and embed share box
When the recipient is decided, mail lands directly: the mail tab's link is a mailto: with subject and body percent-encoded, so pressing it opens a mostly written email, while the embed tab offers a read-only code field and a copy button. Tabs switch by click and by the arrow keys. The underline travels by cell width, translateX(calc(var(--n) * 100%)).
// The underline moves by cell index only — never by a guessed px
function move(i) {
at = (i + tabs.length) % tabs.length;
tabs.forEach(function (t, k) {
t.classList.toggle('is-on', k === at);
t.setAttribute('aria-selected', k === at ? 'true' : 'false');
t.tabIndex = k === at ? 0 : -1; // roving tabindex
panes[k].hidden = k !== at;
});
root.style.setProperty('--n', at);
}
Where it breaks — traps
The first break was the poster frame. In the count-up, the moment the numbers reach their goals (128, 64, 39) and the moment the badges pop must never share a sample cell: twenty-four frames slice two seconds at 4.1667% intervals, and when both events land in one cell the "still counting" frame wins as the biggest change. The shipped keyframes seat the numbers at 50% and pop the badges at 58% — an 8% gap, two whole sample cells — so the poster shows the full values.
Second was the grid column of the side rail. Sizing the rail's column at the collapsed 44px meant the unfolding label climbed on top of the article title — the column had to reserve the full unfolded width. In the same file the collapsed cut was 92px, which left 44px visible — past the 40px where the label starts, letting its first letter peek; widening the cut to 100px pushed the visible 36px back inside the 40px and closed it.
Third was the static state. Three of the nine that looked fine in the .is-demo loop came up blank or doubled once the loop was lifted — 09 had two panels visible at once because the class set display: flex and beat [hidden]'s default display: none, 08 kept its scan line on the hand path, and 03 had no icon on the failure notice. An .ee__panel[hidden] { display: none } redeclaration, a default opacity: 0 on the scan line, and an added cross-mark icon fixed each. Measurement and eyeballing only ever see the loop state — that lesson is recorded here.
Last was the sheet backdrop in 06. A linear fade during the rise let a half-raised, clipped sheet win as the poster frame. Making the backdrop switch in one frame with steps(1, end) after the sheet settles turned the whole-card change into the biggest difference, and the poster became the fully raised sheet with all four cells standing.
Accessibility
Color pairs were computed directly with the WCAG 2.1 relative luminance formula rather than picked by feel; the two rows at the bottom are the pairs this palette refuses to use.
| Foreground | Background | Measured | Body-text bar |
|---|---|---|---|
| ink #15131a | white #ffffff | 18.42:1 | pass |
| ink #15131a | cream #fdf8f0 | 17.42:1 | pass |
| ink #15131a | sky #7fd4f5 | 11.09:1 | pass |
| slate #5b5566 | white #ffffff | 7.15:1 | pass |
| slate #5b5566 | cream #fdf8f0 | 6.76:1 | pass |
| white #ffffff | ink #15131a | 18.42:1 | pass |
| mist #bdb7c6 | ink #15131a | 9.43:1 | pass |
| white #ffffff | deep blue #0f5f8a | 6.94:1 | pass |
| mint #78e0b4 | ink #15131a | 11.51:1 | pass |
| apricot #ffb3a0 | ink #15131a | 10.71:1 | pass |
| ink #15131a | orange #ff4d1f | 5.56:1 | pass |
| white #ffffff | orange #ff4d1f | 3.32:1 | fails — never used |
| white #ffffff | yellow #ffd23f | 1.44:1 | fails — never used |
The nine sets re-rendered with only the passing pairs are in the zip exactly as measured, and the password that opens it is xbet8bb8. Under prefers-reduced-motion: reduce all nine turn off motion and keep state and information. 01 keeps its ring on hover and focus and aria-pressed announcing the pick. 02 stays folded with hover and focus still unfolding labels. 03 reports the copy result through the toast the moment it happens. 04 jumps straight to the declared final values. 05 and 07 sit collapsed while opening still responds instantly. 06 stays open with the focus trap working. 08 keeps the code standing and hides only the scan line. 09 keeps the chosen tab and its panel visible.
The structural devices actually in place: every icon button has an accessible name (text where the label shows, aria-label where only the icon does), the toggle in 01 uses aria-pressed, the openers in 05 and 07 carry aria-expanded with aria-controls, the sheet in 06 is a role="dialog" with aria-modal, a focus trap, and Escape, and the popup in 08 carries the same role. The badges in 04 are decorative aria-hidden with the counts spoken inside each button's accessible name. 09 is a role="tablist" with a roving tabindex so the keyboard alone moves between tabs, and its code field announces that it is read-only. The outcome sentence in 03 lives in a single role="status" with aria-live="polite" so it is read exactly once, in place, for people who cannot see the screen. Copy and dialog guidance follows MDN's Clipboard API docs and the ARIA dialog pattern; contrast against W3C's minimum contrast explanation. For notifications during reading, continue to the toast stack, and for a bar you operate from the bottom of the screen see the floating dock navigation. How this site builds and checks its code is on the about page.
FAQ
How many share buttons should I keep?
Three or four. Four that each do a different job — link, mail, message, QR — as in 01 is plenty; eight platform logos in a row means none of them get pressed. Where space is tight, fold them behind one button as in 05. Pulling platform names and logos in wholesale is also worth avoiding — using trademarks and brand colors uninvited is borrowing trouble.
How do I know the copy actually happened?
Ask the browser. navigator.clipboard.writeText returns a promise, so receive success and failure separately as in 03 and report the outcome in words, with a cross mark for failure. Clipboards do get silently blocked by private modes and permission settings, so an announcement that assumes success becomes a lie. A fallback for browsers without the API at all costs one more branch.
Can't I just drop the QR in as an image?
You can, but this collection does not. Drawing the module matrix as a grid like 08 means it stays crisp at any size, cannot be tinted by mistake, and needs no extra file to carry around. Whichever way you build it, the rules hold — keep the black-on-white contrast, overlay nothing on top, and give the grid an aria-label naming what it holds — 08 uses "QR code holding the URL" for exactly that.