공유하기 버튼 UI 9종 — 붙이면 실제로 복사되는
공유하기 버튼은 방문자가 누른 한 번으로 글을 밖으로 내보내는 마지막 문입니다. 여기 모은 아홉 가지는 겉만 그려 둔 게 아니라 누르면 진짜 복사가 되는 공유 UI 를 HTML·CSS·자바스크립트로 짜 둔 것이라, 그대로 복사해 붙이면 바로 움직입니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 한 줄 공유 줄
- 02 본문 옆 세로 공유 바
- 03 링크 복사 토스트
- 04 공유 수 카운트업
- 05 펼쳐지는 아이콘 공유 버튼
- 06 모바일 공유 시트
- 07 하단 고정 공유 바
- 08 QR 코드 공유 팝업
- 09 메일·퍼가기 공유 박스
아홉 개는 유행 순서가 아니라 방문자가 공유를 만나는 순서로 놓았습니다. 글을 다 읽고 만나는 한 줄 공유 줄(01)로 시작해, 읽는 도중에 만나는 세로 바(02)와 누르자마자 결과를 알려 주는 복사 토스트(03)로 이어집니다. 신뢰를 보여 주는 공유 수 카운트업(04)을 지나면 자리를 아끼는 방법 둘 — 버튼 하나에서 펼쳐지는 아이콘(05)과 하단 고정 바(07)가 나오고, 폰 화면에서는 아래에서 올라오는 공유 시트(06)가 그 몫을 대신합니다. 마지막 둘은 공유의 끝을 닫습니다 — 오프라인을 잇는 QR 팝업(08)과 받는 사람을 직접 고르는 메일·퍼가기 박스(09). 아홉 개가 잉크 #15131a · 크림 #fdf8f0 · 하늘 #7fd4f5 라는 같은 색 값을 공유하므로 두셋을 섞어 써도 색이 어긋나지 않습니다.
01한 줄 공유 줄
글 카드 맨 아래에 라벨과 44px 원형 아이콘 버튼 넷이 한 줄로 서는, 가장 먼저 만들게 되는 모양입니다. 호버·초점에서 버튼이 6px 들리고 겹쳐 둔 원이 scale(.72) 에서 scale(1) 로 차오르는데, 44px 는 손가락이 닿는 최소 면적입니다. 누른 버튼은 aria-pressed 로 눌린 채 남아 무엇을 골랐는지 보여 줍니다.
/* 링은 테두리가 아니라 겹쳐 둔 원 — 커져도 레이아웃을 다시 계산하지 않는다 */
.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;
}
02본문 옆 세로 공유 바
매거진처럼 글이 긴 사이트에서 공유 바는 본문 옆에 세로로 붙고 position: sticky 로 스크롤을 따라갑니다. 버튼은 평소에 라벨을 감추고 있다가 호버·초점에서 옆으로 펼치는데, 펼침은 너비를 늘리는 게 아니라 clip-path: inset(0 100px 0 0 round 999px) 로 잘라 둔 오른쪽을 드러내는 것이라 문서가 다시 계산되지 않습니다. 잘라 둔 뒤 남는 36px 이 라벨 첫 글자가 시작되는 40px(여백 12px+아이콘 20px+간격 8px)보다 작아서 접힌 상태에 글자가 비치지 않습니다.
/* 펼친 폭(136px)으로 상자를 미리 잡고 오른쪽 100px 을 잘라 둔다 */
.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);
}
03링크 복사 토스트
공유의 절반은 복사라서 버튼을 누르면 navigator.clipboard.writeText 로 주소가 실제로 복사되고, 아래에서 올라온 토스트가 결과를 알립니다. 클립보드 API 가 없으면 입력칸을 선택해 "직접 복사해 주세요" 로 안내하고, 브라우저가 막아 실패한 경우에도 확인 표시 대신 가위표를 띄웁니다 — 실패에 체크를 보여 주면 거짓말이 되기 때문입니다. 결과 문장은 role="status" 한 칸이 읽습니다.
function show(msg, failed) {
text.textContent = msg;
root.classList.toggle('is-bad', !!failed); // 막힌 경우엔 확인 표시 대신 가위표
root.classList.add('is-done');
}
navigator.clipboard.writeText(url).then(
function () { show('주소를 복사했습니다', false); },
function () { show('복사가 막혔습니다. 직접 선택해 주세요', true); }
);
04공유 수 카운트업
몇 번 공유됐는지 보여 주는 배지는 신뢰의 숫자인데, 0 에서 목표까지 오르는 데 자바스크립트 카운터가 필요하지 않습니다 — @property 로 타입을 등록한 사용자 지정 속성과 카운터가 그 자리에서 굴립니다. 등록 없이 쓰면 타입이 없어 보간이 안 되고 중간에 한 번 툭 바뀔 뿐이라는 점이 이 항목의 핵심입니다. 값은 inherits: false 로 등록했으니 배지 요소 자신에게 걸어야 합니다.
@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; } /* .is-demo 를 뗀 상태의 최종값 */
05펼쳐지는 아이콘 공유 버튼
헤더가 좁은 원페이지에는 공유 버튼 하나만 두고, 누르면 플랫폼 버튼 넷이 시차를 두고 옆으로 펼쳐집니다. 접힘은 폭을 0 으로 줄이는 게 아니라 왼쪽 끝에서 clip-path 로 완전히 잘라 두는 것이라 자리를 미리 잡아 두니 펼쳐도 옆 글자가 밀리지 않습니다. 접힌 동안 버튼은 tabIndex -1 로 Tab 순서에서 빠지고 Esc 로 접히면 초점이 공유 버튼으로 돌아옵니다.
.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; }
06모바일 공유 시트
폰에서는 화면 아래에서 올라오는 시트가 익숙한 모양이라, 공유 버튼을 누르면 role="dialog" 시트가 올라오고 뒷면이 어두워지며 칸 넷은 시트보다 늦게 떠오릅니다. 접근성의 뼈대는 초점 가두기입니다. 열면 첫 버튼으로 초점이 옮겨 가고 Tab 이 마지막에서 첫 번째로 돌아오며 Esc 와 뒷면 클릭으로 닫히고, 닫힌 뒤에는 초점이 공유 버튼으로 돌아옵니다.
// 초점 가두기 — 시트가 열려 있는 동안 Tab 은 시트 안을 돈다
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 }); }
});
07하단 고정 공유 바
읽는 흐름을 끊지 않으려면 공유 바를 화면 아래에 붙이는데, 바는 문서 안에서 position: sticky; bottom: 0 으로 붙고 손잡이를 누르거나 위로 12px 이상 쓸어 올리면 아이콘 다섯이 펼쳐집니다. 펼침의 높이는 고정 픽셀이 아니라 grid-template-rows: 0fr → 1fr 격자 트랙입니다 — height: auto 는 중간값이 계산되지 않지만 0fr 과 1fr 사이는 계산됩니다. 안쪽 래퍼에 min-height: 0 을 줘야 트랙이 실제로 접힙니다.
.sb__panel {
display: grid;
grid-template-rows: 0fr; /* 접힌 상태 */
overflow: hidden;
transition: grid-template-rows $dur-base $easing;
}
.sb__inner { min-height: 0; } /* 0fr 에서 실제로 접히게 하는 한 줄 */
.sb.is-open .sb__panel { grid-template-rows: 1fr; }
08QR 코드 공유 팝업
명함·부스·간판처럼 손이 닿지 않는 자리에서는 화면의 주소를 카메라로 넘기는데, 이 팝업의 QR 은 이미지 파일이 아니라 검은 칸과 흰 칸을 그리드로 깐 29×29 모듈 행렬이라 확대해도 뭉개지지 않고 색을 덧입히는 실수도 없습니다. 실제로 렌더된 화면을 카메라 라이브러리로 다시 읽어 https://godrichstory.com 이 반환되는 것까지 확인했습니다. 위를 훑는 스캔 선은 미리보기에서만 보입니다.
// 29×29 모듈 행렬. 이미지가 아니라 검은 칸 좌표다 — 행 29개 전체는 zip 원본에 있다
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);
09메일·퍼가기 공유 박스
받는 사람이 정해져 있을 때는 메일이 곧장 닿는데, 메일 탭의 링크는 mailto: 에 제목과 본문이 encodeURIComponent 로 채워져 누르면 편지가 거의 다 쓰인 상태로 열리고, 퍼가기 탭은 읽기 전용 코드칸과 복사 버튼을 줍니다. 탭 전환은 클릭과 좌우 화살표로 됩니다. 밑줄은 칸 폭 단위(translateX(calc(var(--n) * 100%)))로 옮겨갑니다.
// 밑줄은 짐작한 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; // 로빙 탭인덱스
panes[k].hidden = k !== at;
});
root.style.setProperty('--n', at);
}
어디서 깨지는가 — 함정
첫 판에서 깨진 것은 대표 이미지였습니다. 04 카운트업에서 숫자가 목표(128·64·39)에 닿는 순간과 배지가 튀는 순간은 같은 샘플 칸에 들면 안 됩니다. 24프레임은 2초를 4.1667% 간격으로 자르는데, 두 사건이 한 칸에 몰리면 "아직 센 중간 숫자"를 찍은 장의 변화량이 가장 커 그 장이 대표로 뽑히기 때문입니다. 지금 소스는 숫자가 50% 에 목표에 닿고 58% 에 튐을 둘러 두 사건을 8% 간격, 샘플 두 칸으로 떼어 놓아, 값이 다 찬 뒤의 배지 튐이 대표 이미지가 되게 했습니다.
두 번째는 02 세로 바의 칸 폭이었습니다. 바가 들어갈 격자 칸을 접힌 폭 44px 로 잡았더니 펼치는 순간 라벨이 본문 제목 위로 얹혔습니다 — 펼친 폭 전체를 칸으로 미리 잡아야 했습니다. 같은 파일에서 접힌 상태의 잘라 둘 폭도 92px 로 잡았다가 라벨 첫 글자의 왼쪽 4px 이 비쳤고, 잘라 둘 폭을 100px 로 넓혀 남는 폭(36px)이 라벨 시작점(40px)보다 작아지게 해결했습니다.
세 번째는 정적 상태였습니다. 아홉 개는 .is-demo 루프에서는 멀쩡했는데 그중 셋은 루프를 뗀 상태가 비거나 겹쳤습니다 — 09 는 판에 display: flex 를 클래스로 줘서 [hidden] 의 기본 display: none 을 이겨 두 판이 함께 보였고, 08 은 손으로 볼 때 스캔 선이 남아 있었으며, 03 은 실패 안내에 아이콘이 없었습니다. .ee__panel[hidden] { display: none } 재선언, 스캔 선의 opacity: 0 기본값, 가위표 아이콘 추가로 각각 고쳤습니다. 측정과 눈검수가 보는 건 루프 상태뿐이라는 걸 몸으로 배웠습니다.
마지막으로 06 시트의 뒷면이었습니다. 처음엔 시트가 올라오는 동안 linear 페이드로 어두워지게 했더니 반쯤 올라온 시트가 잘린 장이 대표 이미지로 뽑혔습니다. 뒷면이 시트가 선 뒤 한 프레임에 steps(1, end) 로 바뀌게 하니 카드 전체가 한 번에 바뀌는 그 장이 최대 변화가 되어, 칸 넷이 다 선 시트가 대표가 됐습니다.
접근성
색 배합은 감으로 고르지 않았습니다 — WCAG 2.1 의 상대 휘도 식으로 조합마다 직접 계산했고, 쓰지 않기로 한 짝은 맨 아래 두 줄뿐입니다.
| 앞 | 뒤 | 실측 대비 | 본문 기준 |
|---|---|---|---|
| 잉크 #15131a | 흰 #ffffff | 18.42:1 | 통과 |
| 잉크 #15131a | 크림 #fdf8f0 | 17.42:1 | 통과 |
| 잉크 #15131a | 하늘 #7fd4f5 | 11.09:1 | 통과 |
| 슬레이트 #5b5566 | 흰 #ffffff | 7.15:1 | 통과 |
| 슬레이트 #5b5566 | 크림 #fdf8f0 | 6.76:1 | 통과 |
| 흰 #ffffff | 잉크 #15131a | 18.42:1 | 통과 |
| 미스트 #bdb7c6 | 잉크 #15131a | 9.43:1 | 통과 |
| 흰 #ffffff | 딥블루 #0f5f8a | 6.94:1 | 통과 |
| 민트 #78e0b4 | 잉크 #15131a | 11.51:1 | 통과 |
| 살구 #ffb3a0 | 잉크 #15131a | 10.71:1 | 통과 |
| 잉크 #15131a | 주황 #ff4d1f | 5.56:1 | 통과 |
| 흰 #ffffff | 주황 #ff4d1f | 3.32:1 | 미달 — 쓰지 않는다 |
| 흰 #ffffff | 노랑 #ffd23f | 1.44:1 | 미달 — 쓰지 않는다 |
이렇게 통과한 짝만 골라 다시 렌더한 아홉 벌이 zip 에 그대로 들어 있고, 여는 비밀번호는 xbet8bb8 입니다. 움직임을 덜어 달라는 설정(prefers-reduced-motion: reduce)에서는 아홉 개 모두 움직임만 끄고 상태와 정보는 남겨 둡니다. 01 은 호버·초점의 링이 그대로 보이고 aria-pressed 가 고른 버튼을 알립니다. 02 는 라벨이 접힌 채 멈추되 호버·초점의 펼침은 살아 있습니다. 03 은 복사 결과를 토스트로 그대로 알립니다. 04 는 숫자가 선언된 최종값으로 바로 찹니다. 05 와 07 은 접힌 상태로 멈추되 여닫기 자체는 즉시 반응합니다. 06 은 시트가 연 채 서고 초점 가두기는 그대로 작동합니다. 08 은 코드가 서 있고 스캔 선만 사라집니다. 09 는 고른 탭과 그 판이 그대로 보입니다.
화면 구조 쪽에 실제로 들어간 장치는 이렇습니다. 아이콘 버튼은 전부 접근성 이름을 갖고 있고(라벨이 보이면 텍스트로, 아이콘만 있으면 aria-label 로), 01 의 토글은 aria-pressed, 05·07 의 여닫기는 aria-expanded 와 aria-controls 를 씁니다. 06 시트는 role="dialog" 와 aria-modal, 초점 가두기, Esc 닫기를 실제로 구현했고 08 팝업도 같은 역할 이름을 씁니다. 04 의 배지는 장식이라 aria-hidden 이고 숫자는 버튼의 접근성 이름에 말로 들어 있습니다. 09 는 role="tablist" 와 로빙 탭인덱스로 키보드만으로 탭을 오가며, 코드칸은 읽기 전용임을 알립니다. 03 의 결과 문장은 role="status" 와 aria-live="polite" 로 화면을 못 보는 사람에게 같은 자리에서 한 번만 읽힙니다. 복사·여닫기 근거는 MDN 의 Clipboard API 문서와 aria-dialog 안내, 대비 기준은 W3C 의 최소 명도 대비 해설을 따랐습니다. 글 읽는 도중의 알림이 궁금하면 토스트 쌓기를, 화면을 아래에서 조작하는 바가 필요하면 플로팅 독 내비게이션으로 이어집니다. 이 사이트에서 코드를 어떻게 만들고 확인하는지는 소개에 남겨 두었습니다.
FAQ
공유 버튼은 몇 개까지 두는 게 좋나요?
셋에서 넷입니다. 01 의 링크·메일·메시지·QR 처럼 하는 일이 다른 넷이면 충분하고, 플랫폼 로고를 여덟 개 늘어놓으면 오히려 하나도 안 눌립니다. 자리가 좁으면 05 처럼 버튼 하나로 접었다 펼치는 편이 낫습니다. 플랫폼 이름과 로고를 그대로 가져오는 것도 피하세요 — 상표와 브랜드 색을 무단으로 쓰는 셈이 됩니다.
복사가 되었다고 어떻게 확신하나요?
브라우저에게 물어야 합니다. navigator.clipboard.writeText 는 약속을 돌려주므로 03 처럼 성공·실패를 각각 받아 결과를 문장으로 알리게 하고, 실패에는 가위표를 보여 주세요. 시크릿 모드나 권한 설정에서 클립보드는 조용히 막히는 일이 있어서, 성공만 가정한 안내는 거짓말이 됩니다. API 자체가 없는 오래된 브라우저용 대비책도 함께 두면 됩니다.
QR 은 이미지로 넣으면 안 되나요?
넣을 수 있지만 이 모음은 그러지 않았습니다. 08 처럼 모듈 행렬을 그리드로 그리면 확대해도 뭉개지지 않고, 색을 덧입히는 실수가 없으며, 파일 하나를 더 들고 다니지 않아도 됩니다. 어떤 방식이든 지켜야 할 규칙은 같습니다 — 검은 칸과 흰 칸의 대비를 유지하고, 위에 다른 요소를 얹지 않고, 코드가 무엇을 담았는지 aria-label 로 알려 주는 것입니다 — 08 은 「주소를 담은 QR 코드」 라고 이름 붙였습니다.