06모바일 공유 시트
공유 버튼을 누르면 화면 아래에서 시트가 올라와 플랫폼 넷과 링크 복사가 나란히 놓이고 뒷면이 어두워집니다. Esc 와 바깥 클릭으로 닫히며 초점은 시트 안을 벗어나지 않습니다.
쓰임인앱 브라우저로 보는 콘텐츠, 모바일 최적화 사이트
translateY 바텀시트role=dialogfocus trap

<div class="stage">
<div class="ms">
<div class="ms__screen">
<p class="ms__title">오늘의 기록</p>
<span class="ms__row ms__row--full" aria-hidden="true"></span>
<span class="ms__row ms__row--wide" aria-hidden="true"></span>
<span class="ms__row ms__row--mid" aria-hidden="true"></span>
<button class="ms__open" type="button">
<span class="ms__ic"><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M152,192V144c-61.4,0-104.61,37.19-121.07,54.72a4,4,0,0,1-6.9-3.18C31.51,130.45,99.19,80,152,80V32l80,80Z" opacity="0.2"/><path d="M237.66,106.35l-80-80A8,8,0,0,0,144,32V72.35c-25.94,2.22-54.59,14.92-78.16,34.91-28.38,24.08-46.05,55.11-49.76,87.37a12,12,0,0,0,20.68,9.58h0c11-11.71,50.14-48.74,107.24-52V192a8,8,0,0,0,13.66,5.65l80-80A8,8,0,0,0,237.66,106.35ZM160,172.69V144a8,8,0,0,0-8-8c-28.08,0-55.43,7.33-81.29,21.8a196.17,196.17,0,0,0-36.57,26.52c5.8-23.84,20.42-46.51,42.05-64.86C99.41,99.77,127.75,88,152,88a8,8,0,0,0,8-8V51.32L220.69,112Z"/></svg></span>
<span>공유</span>
</button>
</div>
<div class="ms__scrim" aria-hidden="true"></div>
<div class="ms__sheet" role="dialog" aria-modal="true" aria-label="공유 방법 고르기">
<span class="ms__grip" aria-hidden="true"></span>
<div class="ms__grid">
<button class="ms__pick" type="button">
<span class="ms__pickIc"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg></span>
<span class="ms__pickName">링크 복사</span>
</button>
<button class="ms__pick" type="button">
<span class="ms__pickIc"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg></span>
<span class="ms__pickName">메일</span>
</button>
<button class="ms__pick" type="button">
<span class="ms__pickIc"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg></span>
<span class="ms__pickName">메시지</span>
</button>
<button class="ms__pick" type="button">
<span class="ms__pickIc"><svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M112,56v48a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8h48A8,8,0,0,1,112,56Zm-8,88H56a8,8,0,0,0-8,8v48a8,8,0,0,0,8,8h48a8,8,0,0,0,8-8V152A8,8,0,0,0,104,144Zm96-96H152a8,8,0,0,0-8,8v48a8,8,0,0,0,8,8h48a8,8,0,0,0,8-8V56A8,8,0,0,0,200,48Z" opacity="0.2"/><path d="M104,40H56A16,16,0,0,0,40,56v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V56A16,16,0,0,0,104,40Zm0,64H56V56h48v48Zm0,32H56a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V152A16,16,0,0,0,104,136Zm0,64H56V152h48v48ZM200,40H152a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,64H152V56h48v48Zm-64,72V144a8,8,0,0,1,16,0v32a8,8,0,0,1-16,0Zm80-16a8,8,0,0,1-8,8H184v40a8,8,0,0,1-8,8H144a8,8,0,0,1,0-16h24V144a8,8,0,0,1,16,0v8h24A8,8,0,0,1,216,160Zm0,32v16a8,8,0,0,1-16,0V192a8,8,0,0,1,16,0Z"/></svg></span>
<span class="ms__pickName">QR 코드</span>
</button>
</div>
<button class="ms__close" type="button">닫기</button>
</div>
</div>
</div>@use "../../../../site/static/demo-tokens" as *;
// 편 277 공유 팔레트 (01 과 같은 값)
$sh-ink: #15131a;
$sh-ink2: #26232e;
$sh-paper: #ffffff;
$sh-cream: #fdf8f0;
$sh-slate: #5b5566;
$sh-deep: #477690;
$duration: $dur-loop; // 열림 → 머무름 한 바퀴
$easing: $ease-spring;
$color: $sh-deep; // 고른 칸 강조 색
@include stage($sh-paper);
.ms {
position: relative;
box-sizing: border-box;
width: clamp(232px, 72vw, 272px);
height: 216px;
overflow: hidden;
border-radius: $r-card;
background: $sh-cream;
box-shadow: $shadow-raised;
}
.ms__screen {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-4;
}
.ms__title {
margin: 0;
color: $sh-ink;
font-weight: 700; font-size: 14px; line-height: 1.2;
font-family: inherit;
}
.ms__row {
display: block;
height: 8px;
border-radius: $r-pill;
background: #e3ddd2;
}
.ms__row--full { width: 100%; }
.ms__row--wide { width: 86%; }
.ms__row--mid { width: 60%; }
.ms__open {
align-self: flex-start;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
margin-top: $sp-2;
height: 36px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $sh-ink;
color: $sh-paper;
cursor: pointer;
font-weight: 700; font-size: 12px; line-height: 1;
font-family: inherit;
}
.ms__open:focus-visible {
outline: 2px solid $sh-ink;
outline-offset: 2px;
}
.ms__ic { display: grid; place-items: center; }
.ms__ic svg { display: block; }
// 뒷면 — 시트가 열린 동안만 어두워진다
.ms__scrim {
position: absolute;
inset: 0;
background: rgba(21, 19, 26, .48);
opacity: 0;
pointer-events: none;
transition: opacity $dur-base $easing;
}
.ms__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2 $sp-3 $sp-3;
border-radius: $r-card $r-card 0 0;
background: $sh-paper;
transform: translateY(100%);
transition: transform $dur-base $easing;
}
.ms__grip {
align-self: center;
width: 36px;
height: 4px;
border-radius: $r-pill;
background: #ddd6ca;
}
.ms__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: $sp-1;
}
.ms__pick {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
padding: $sp-2 0;
border: 0;
border-radius: $r-control;
background: $sh-cream;
color: $sh-ink;
cursor: pointer;
transform: translateY(10px);
transition: transform $dur-base $easing, background-color $dur-quick $easing;
}
.ms__pick:hover, .ms__pick:focus-visible { background: #f0e9dd; }
.ms__pick:focus-visible {
outline: 2px solid $color;
outline-offset: 1px;
}
.ms__pickIc { display: grid; place-items: center; color: $color; }
.ms__pickIc svg { display: block; }
.ms__pickName {
font-weight: 600; font-size: 10px; line-height: 1.1;
font-family: inherit;
}
.ms__close {
box-sizing: border-box;
height: 32px;
border: 0;
border-radius: $r-control;
background: $sh-cream;
color: $sh-slate; // 크림 위 보조 글자 6.76:1
cursor: pointer;
font-weight: 700; font-size: 12px; line-height: 1;
font-family: inherit;
}
.ms__close:focus-visible {
outline: 2px solid $color;
outline-offset: 1px;
}
// 손으로 쓰는 경로 — .is-demo 를 뗀 뒤에도 열림 상태가 그대로 보인다
.ms.is-open .ms__scrim { opacity: 1; pointer-events: auto; }
.ms.is-open .ms__sheet { transform: translateY(0); }
.ms.is-open .ms__pick { transform: translateY(0); }
.ms.is-open .ms__pick:nth-child(1) { transition-delay: 60ms; }
.ms.is-open .ms__pick:nth-child(2) { transition-delay: 100ms; }
.ms.is-open .ms__pick:nth-child(3) { transition-delay: 140ms; }
.ms.is-open .ms__pick:nth-child(4) { transition-delay: 180ms; }
// ── 자동 재생 ───────────────────────────────────────────────
// 시트는 루프 안에서 닫히지 않는다 — 대표 이미지는 앞 장과 가장 크게 달라진 장이라
// 닫힘을 재현하면 시트 없는 장이 뽑힌다. 되돌림은 루프 경계에서만 일어난다
.is-demo .ms__sheet {
animation-name: msRise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 뒷면은 시트가 자리에 선 뒤에 한 프레임으로 어두워진다 — 카드 전체가 한 번에 바뀌므로
// 그 장이 가장 크게 달라진 장이 되고, 대표 이미지가 "반쯤 올라온 시트" 대신 다 선 시트가 된다
// (첫 렌더 실측: 시트가 오르는 중간 장이 뽑혀 칸 넷이 카드 밖으로 잘려 보였다)
.is-demo .ms__scrim {
animation-name: msDim;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 칸 넷이 시트보다 늦게 올라와 프레임을 채운다. 시차에는 fill-mode: backwards 를 같이 준다
.is-demo .ms__pick {
animation-name: msPop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ms__pick:nth-child(1) { animation-delay: .14s; }
.is-demo .ms__pick:nth-child(2) { animation-delay: .22s; }
.is-demo .ms__pick:nth-child(3) { animation-delay: .30s; }
.is-demo .ms__pick:nth-child(4) { animation-delay: .38s; }
@keyframes msRise {
0%, 6% { transform: translateY(100%); }
34%, 100% { transform: translateY(0); }
}
@keyframes msDim {
0%, 37.4% { opacity: 0; }
37.5%, 100% { opacity: 1; }
}
@keyframes msPop {
0% { transform: translateY(10px); }
30%, 100% { transform: translateY(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.ms { height: 168px; }
.ms__screen { gap: $sp-1; padding: $sp-3; }
.ms__title { font-size: 13px; }
.ms__row { height: 6px; }
.ms__open { margin-top: 0; height: 32px; padding: 0 $sp-2; }
.ms__sheet { gap: $sp-1; padding: $sp-1 $sp-2 $sp-2; }
.ms__pick { padding: $sp-1 0; }
.ms__pickIc svg { width: 16px; height: 16px; }
.ms__pickName { font-size: 9px; }
.ms__close { height: 28px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
// .is-demo 쪽 선택자가 (0,2,0) 이라 !important 없이는 진다
.is-demo .ms__sheet, .is-demo .ms__scrim, .is-demo .ms__pick { animation: none !important; }
.ms__sheet, .ms__scrim, .ms__pick { transition: none !important; }
// 움직임을 끊어도 열린 시트와 칸 넷은 제자리에 그대로 선다
.ms.is-open .ms__sheet { transform: translateY(0); }
.ms.is-open .ms__pick { transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ms {
position: relative;
box-sizing: border-box;
width: clamp(232px, 72vw, 272px);
height: 216px;
overflow: hidden;
border-radius: 16px;
background: #fdf8f0;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ms__screen {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
}
.ms__title {
margin: 0;
color: #15131a;
font-weight: 700;
font-size: 14px;
line-height: 1.2;
font-family: inherit;
}
.ms__row {
display: block;
height: 8px;
border-radius: 999px;
background: #e3ddd2;
}
.ms__row--full {
width: 100%;
}
.ms__row--wide {
width: 86%;
}
.ms__row--mid {
width: 60%;
}
.ms__open {
align-self: flex-start;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
height: 36px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #15131a;
color: #ffffff;
cursor: pointer;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.ms__open:focus-visible {
outline: 2px solid #15131a;
outline-offset: 2px;
}
.ms__ic {
display: grid;
place-items: center;
}
.ms__ic svg {
display: block;
}
.ms__scrim {
position: absolute;
inset: 0;
background: rgba(21, 19, 26, 0.48);
opacity: 0;
pointer-events: none;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ms__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 12px 12px;
border-radius: 16px 16px 0 0;
background: #ffffff;
transform: translateY(100%);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ms__grip {
align-self: center;
width: 36px;
height: 4px;
border-radius: 999px;
background: #ddd6ca;
}
.ms__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
}
.ms__pick {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 0;
border: 0;
border-radius: 12px;
background: #fdf8f0;
color: #15131a;
cursor: pointer;
transform: translateY(10px);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ms__pick:hover, .ms__pick:focus-visible {
background: #f0e9dd;
}
.ms__pick:focus-visible {
outline: 2px solid #477690;
outline-offset: 1px;
}
.ms__pickIc {
display: grid;
place-items: center;
color: #477690;
}
.ms__pickIc svg {
display: block;
}
.ms__pickName {
font-weight: 600;
font-size: 10px;
line-height: 1.1;
font-family: inherit;
}
.ms__close {
box-sizing: border-box;
height: 32px;
border: 0;
border-radius: 12px;
background: #fdf8f0;
color: #5b5566;
cursor: pointer;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.ms__close:focus-visible {
outline: 2px solid #477690;
outline-offset: 1px;
}
.ms.is-open .ms__scrim {
opacity: 1;
pointer-events: auto;
}
.ms.is-open .ms__sheet {
transform: translateY(0);
}
.ms.is-open .ms__pick {
transform: translateY(0);
}
.ms.is-open .ms__pick:nth-child(1) {
transition-delay: 60ms;
}
.ms.is-open .ms__pick:nth-child(2) {
transition-delay: 100ms;
}
.ms.is-open .ms__pick:nth-child(3) {
transition-delay: 140ms;
}
.ms.is-open .ms__pick:nth-child(4) {
transition-delay: 180ms;
}
.is-demo .ms__sheet {
animation-name: msRise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .ms__scrim {
animation-name: msDim;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .ms__pick {
animation-name: msPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.is-demo .ms__pick:nth-child(1) {
animation-delay: 0.14s;
}
.is-demo .ms__pick:nth-child(2) {
animation-delay: 0.22s;
}
.is-demo .ms__pick:nth-child(3) {
animation-delay: 0.3s;
}
.is-demo .ms__pick:nth-child(4) {
animation-delay: 0.38s;
}
@keyframes msRise {
0%, 6% {
transform: translateY(100%);
}
34%, 100% {
transform: translateY(0);
}
}
@keyframes msDim {
0%, 37.4% {
opacity: 0;
}
37.5%, 100% {
opacity: 1;
}
}
@keyframes msPop {
0% {
transform: translateY(10px);
}
30%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ms {
height: 168px;
}
.ms__screen {
gap: 4px;
padding: 12px;
}
.ms__title {
font-size: 13px;
}
.ms__row {
height: 6px;
}
.ms__open {
margin-top: 0;
height: 32px;
padding: 0 8px;
}
.ms__sheet {
gap: 4px;
padding: 4px 8px 8px;
}
.ms__pick {
padding: 4px 0;
}
.ms__pickIc svg {
width: 16px;
height: 16px;
}
.ms__pickName {
font-size: 9px;
}
.ms__close {
height: 28px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .ms__sheet, .is-demo .ms__scrim, .is-demo .ms__pick {
animation: none !important;
}
.ms__sheet, .ms__scrim, .ms__pick {
transition: none !important;
}
.ms.is-open .ms__sheet {
transform: translateY(0);
}
.ms.is-open .ms__pick {
transform: translateY(0);
}
}(function () {
var body = document.body;
var root = document.querySelector('.ms');
var open = document.querySelector('.ms__open');
var sheet = document.querySelector('.ms__sheet');
var scrim = document.querySelector('.ms__scrim');
var close = document.querySelector('.ms__close');
function hand() { body.classList.remove('is-demo'); } // 사람이 손을 대면 자동 재생을 끈다
function focusables() {
return Array.prototype.slice.call(sheet.querySelectorAll('button'));
}
function setOpen(on) {
root.classList.toggle('is-open', on);
sheet.setAttribute('aria-hidden', on ? 'false' : 'true');
focusables().forEach(function (b) { b.tabIndex = on ? 0 : -1; });
if (on) focusables()[0].focus({ preventScroll: true });
else open.focus({ preventScroll: true });
}
open.addEventListener('click', function () { hand(); setOpen(true); });
open.addEventListener('pointerdown', hand);
open.addEventListener('focusin', hand);
close.addEventListener('click', function () { hand(); setOpen(false); });
scrim.addEventListener('click', function () { hand(); setOpen(false); });
// 초점 가두기 — 시트가 열려 있는 동안 Tab 은 시트 안을 돈다
sheet.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { hand(); 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 }); }
});
sheet.setAttribute('aria-hidden', 'true');
focusables().forEach(function (b) { b.tabIndex = -1; });
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./ShareMobileSheet.module.scss";
/* ambient 에 React 의 이벤트 제네릭이 없어 필요한 것만 로컬로 선언한다 */
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
type FocusBox = { focus(opts: { preventScroll: boolean }): void };
type SheetEl = { querySelectorAll(sel: string): FocusBox[] };
export type ShareMobileSheetProps = {
/** 시트 안 칸 이름 */
picks?: string[];
/** 열림 → 머무름 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 고른 칸 강조 색 */
color?: string;
autoPlay?: boolean;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/* Phosphor Duotone (MIT) — 사슬 · 봉투 · 말풍선 · QR */
const ICONS: ReactNode[] = [
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M218.34,119.6,183.6,154.34a46.58,46.58,0,0,1-44.31,12.26c-.31.34-.62.67-.95,1L103.6,202.34A46.63,46.63,0,1,1,37.66,136.4L72.4,101.66A46.6,46.6,0,0,1,116.71,89.4c.31-.34.62-.67,1-1L152.4,53.66a46.63,46.63,0,0,1,65.94,65.94Z" opacity="0.2"/><path d="M240,88.23a54.43,54.43,0,0,1-16,37L189.25,160a54.27,54.27,0,0,1-38.63,16h-.05A54.63,54.63,0,0,1,96,119.84a8,8,0,0,1,16,.45A38.62,38.62,0,0,0,150.58,160h0a38.39,38.39,0,0,0,27.31-11.31l34.75-34.75a38.63,38.63,0,0,0-54.63-54.63l-11,11A8,8,0,0,1,135.7,59l11-11A54.65,54.65,0,0,1,224,48,54.86,54.86,0,0,1,240,88.23ZM109,185.66l-11,11A38.41,38.41,0,0,1,70.6,208h0a38.63,38.63,0,0,1-27.29-65.94L78,107.31A38.63,38.63,0,0,1,144,135.71a8,8,0,0,0,7.78,8.22H152a8,8,0,0,0,8-7.78A54.86,54.86,0,0,0,144,96a54.65,54.65,0,0,0-77.27,0L32,130.75A54.62,54.62,0,0,0,70.56,224h0a54.28,54.28,0,0,0,38.64-16l11-11A8,8,0,0,0,109,185.66Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,56l-96,88L32,56Z" opacity="0.2"/><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128A96,96,0,0,1,79.93,211.11h0L42.54,223.58a8,8,0,0,1-10.12-10.12l12.47-37.39h0A96,96,0,1,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-4-1.08,7.85,7.85,0,0,0-2.53.42L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Zm12-88a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm-44,0a12,12,0,1,1-12-12A12,12,0,0,1,96,128Zm88,0a12,12,0,1,1-12-12A12,12,0,0,1,184,128Z"/></svg>,
<svg width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M112,56v48a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8h48A8,8,0,0,1,112,56Zm-8,88H56a8,8,0,0,0-8,8v48a8,8,0,0,0,8,8h48a8,8,0,0,0,8-8V152A8,8,0,0,0,104,144Zm96-96H152a8,8,0,0,0-8,8v48a8,8,0,0,0,8,8h48a8,8,0,0,0,8-8V56A8,8,0,0,0,200,48Z" opacity="0.2"/><path d="M104,40H56A16,16,0,0,0,40,56v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V56A16,16,0,0,0,104,40Zm0,64H56V56h48v48Zm0,32H56a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V152A16,16,0,0,0,104,136Zm0,64H56V152h48v48ZM200,40H152a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,64H152V56h48v48Zm-64,72V144a8,8,0,0,1,16,0v32a8,8,0,0,1-16,0Zm80-16a8,8,0,0,1-8,8H184v40a8,8,0,0,1-8,8H144a8,8,0,0,1,0-16h24V144a8,8,0,0,1,16,0v8h24A8,8,0,0,1,216,160Zm0,32v16a8,8,0,0,1-16,0V192a8,8,0,0,1,16,0Z"/></svg>,
];
export default function ShareMobileSheet({
picks = ["링크 복사", "메일", "메시지", "QR 코드"],
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0f5f8a",
autoPlay = true,
}: ShareMobileSheetProps) {
const [demo, setDemo] = useState(autoPlay);
const [open, setOpen] = useState(false);
const sheetRef = useRef<SheetEl>(null) as { current: SheetEl | null };
const openRef = useRef<FocusBox>(null) as { current: FocusBox | null };
/* 사람이 손을 대면 자동 재생을 끈다 */
function stopDemo() {
setDemo(false);
}
function applyOpen(on: boolean) {
stopDemo();
setOpen(on);
const list = sheetRef.current ? sheetRef.current.querySelectorAll("button") : [];
if (on && list.length) list[0].focus({ preventScroll: true });
if (!on && openRef.current) openRef.current.focus({ preventScroll: true });
}
/* 초점 가두기 — 시트가 열려 있는 동안 Tab 은 시트 안을 돈다 */
function applyKey(e: KeyEvt) {
if (e.key === "Escape") {
applyOpen(false);
return;
}
if (e.key !== "Tab" || !sheetRef.current) return;
const list = sheetRef.current.querySelectorAll("button");
const last = list.length - 1;
const at = list.indexOf(document.activeElement as unknown as FocusBox);
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 });
}
}
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<div className={styles.stage + (demo ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.phone + (open ? " " + styles.isOpen : "")}>
<div className={styles.screen}>
<p className={styles.title}>오늘의 기록</p>
<span className={styles.row + " " + styles.rowFull} aria-hidden="true" />
<span className={styles.row + " " + styles.rowWide} aria-hidden="true" />
<span className={styles.row + " " + styles.rowMid} aria-hidden="true" />
<button
className={styles.open}
type="button"
onClick={() => applyOpen(true)}
onPointerDown={stopDemo}
onFocus={stopDemo}
ref={openRef as unknown as never}
>
<span className={styles.ic}><svg width="18" height="18" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M152,192V144c-61.4,0-104.61,37.19-121.07,54.72a4,4,0,0,1-6.9-3.18C31.51,130.45,99.19,80,152,80V32l80,80Z" opacity="0.2"/><path d="M237.66,106.35l-80-80A8,8,0,0,0,144,32V72.35c-25.94,2.22-54.59,14.92-78.16,34.91-28.38,24.08-46.05,55.11-49.76,87.37a12,12,0,0,0,20.68,9.58h0c11-11.71,50.14-48.74,107.24-52V192a8,8,0,0,0,13.66,5.65l80-80A8,8,0,0,0,237.66,106.35ZM160,172.69V144a8,8,0,0,0-8-8c-28.08,0-55.43,7.33-81.29,21.8a196.17,196.17,0,0,0-36.57,26.52c5.8-23.84,20.42-46.51,42.05-64.86C99.41,99.77,127.75,88,152,88a8,8,0,0,0,8-8V51.32L220.69,112Z"/></svg></span>
<span>공유</span>
</button>
</div>
<div className={styles.scrim} aria-hidden="true" onClick={() => applyOpen(false)} />
<div
className={styles.sheet}
role="dialog"
aria-modal="true"
aria-label="공유 방법 고르기"
aria-hidden={!open}
onKeyDown={applyKey}
ref={sheetRef as unknown as never}
>
<span className={styles.grip} aria-hidden="true" />
<div className={styles.grid}>
{picks.map((name, i) => (
<button key={name} className={styles.pick} type="button" tabIndex={open ? 0 : -1}>
<span className={styles.pickIc}>{ICONS[i]}</span>
<span className={styles.pickName}>{name}</span>
</button>
))}
</div>
<button
className={styles.close}
type="button"
tabIndex={open ? 0 : -1}
onClick={() => applyOpen(false)}
>
닫기
</button>
</div>
</div>
</div>
);
}06 모바일 공유 시트 — 측정 누적면적 39.759% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 공유하기 버튼 UI 9종 — 붙이면 실제로 복사되는
이 데모가 속한 글
공유하기 버튼 UI 9종 — 붙이면 실제로 복사되는공유하기 버튼은 방문자가 누른 한 번으로 글을 밖으로 내보내는 마지막 문입니다. 여기 모은 아홉 가지는 겉만 그려 둔 게 아니라 누르면 진짜 복사가 되는 공유 UI 를 HTML·CSS·자바스크립트로 짜 둔 것이라, 이 글의 데모 전부 보기


