08누르면 채팅 창이 열리는 도움말 버튼
도움말 FAB 을 누르면 말풍선 판이 translateY 로 열리고 보낸 말은 실제로 쌓이며, 상대의 답 자리는 aria-live 로 읽힙니다.
쓰임문의·안내를 오른쪽 아래에 두는 서비스 화면
aria-expandedtranslateYaria-live
<main class="stage">
<section class="ch is-demo" id="ch" aria-label="채팅 창을 여는 도움말 버튼">
<div class="ch__panel" id="ch-panel" role="dialog" aria-label="도움말 채팅" aria-modal="false">
<header class="ch__head">
<span class="ch__name">무엇을 도와드릴까요?</span>
</header>
<div class="ch__log" id="ch-log" aria-live="polite">
<p class="ch__bubble ch__bubble--bot">궁금한 점을 적어 주세요. 평일 낮에는 사람이 답합니다.</p>
<p class="ch__bubble ch__loop-me" aria-hidden="true">배송 언제예요?</p>
<p class="ch__bubble ch__loop-dots" aria-hidden="true">...</p>
<p class="ch__bubble ch__loop-reply" aria-hidden="true">내일 아침에 먼저 출발합니다.</p>
</div>
<form class="ch__form" id="ch-form">
<input class="ch__input" id="ch-input" type="text" placeholder="메시지를 적어 보세요" aria-label="메시지 입력" />
<button class="ch__send" type="submit" aria-label="메시지 보내기">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M227.91,134.86,59.93,231a8,8,0,0,1-11.44-9.67L80,128,48.49,34.72a8,8,0,0,1,11.44-9.67l168,95.85A8,8,0,0,1,227.91,134.86Z" opacity=".2"/><path d="M231.87,114l-168-95.89A16,16,0,0,0,40.92,37.34L71.55,128,40.92,218.67A16,16,0,0,0,56,240a16.15,16.15,0,0,0,7.93-2.1l167.92-96.05a16,16,0,0,0,.05-27.89ZM56,224a.56.56,0,0,0,0-.12L85.74,136H144a8,8,0,0,0,0-16H85.74L56.06,32.16A.46.46,0,0,0,56,32l168,95.83Z"/></svg></span>
</button>
</form>
</div>
<button class="ch__fab" type="button" id="ch-fab" aria-expanded="false" aria-controls="ch-panel" aria-label="도움말 채팅 열기">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" 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=".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>
</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 도움말 FAB 과 채팅 창 — 판은 translateY 로 열리고 대화는 실제로 쌓인다
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-orange;
@include stage($stage-paper);
.ch {
position: relative;
display: grid;
justify-items: end;
align-content: end;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 판: 절대배치 대신 자리를 확보해 320px 넘침을 막는다 (보임만 변한다)
.ch__panel {
box-sizing: border-box;
width: min(236px, 92vw);
height: 148px;
display: grid;
grid-template-rows: auto 1fr auto;
padding: $sp-2;
border: 1px solid rgba(23, 20, 26, .14);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-press;
transform: translateY(16px) scale(.96);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $dur-instant linear;
}
.ch__head {
display: flex;
align-items: center;
margin-bottom: $sp-1;
}
.ch__name { font-size: 11px; font-weight: 700; line-height: 1.3; }
.ch__log {
display: grid;
align-content: start;
gap: $sp-1;
overflow: hidden;
}
.ch__bubble {
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-control;
font-size: 11px;
line-height: 1.45;
}
.ch__bubble--bot {
justify-self: start;
background: rgba(23, 20, 26, .07);
}
.ch__bubble--me {
justify-self: end;
background: $stage-orange;
color: $subject-cream;
}
.ch__form {
display: flex;
gap: $sp-1;
margin-top: $sp-1;
}
.ch__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
height: 32px;
margin: 0;
padding: 0 $sp-2;
border: 1px solid rgba(23, 20, 26, .22);
border-radius: $r-pill;
background: transparent;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.ch__input::placeholder { color: rgba(23, 20, 26, .45); }
.ch__input:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.ch__send {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: $stage-orange;
color: $subject-cream;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.ch__send:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ch__fab {
display: inline-grid;
place-items: center;
width: 48px;
height: 48px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: $subject-ink;
color: $stage-orange;
font-size: 24px;
line-height: 1;
cursor: pointer;
box-shadow: $shadow-raised;
transition: transform $dur-instant $ease-pop;
}
.ch__fab:focus-visible { outline: 3px solid $subject-cream; outline-offset: 3px; }
.ch__fab:active { transform: scale(.92); }
// 정적 경로 — 열리면 판이 제자리에 살아난다
.ch.is-open .ch__panel {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
// ── 격자 미리보기 전용 2초 루프: 열림 → 보낸 말 → 답 (닫힘은 재현하지 않는다) ──
@keyframes ch-loop-panel {
0%, 9% { transform: translateY(16px) scale(.96); opacity: 0; }
10% { opacity: 1; }
28%, 100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes ch-loop-me {
0%, 42% { transform: translateY(10px); opacity: 0; }
43% { opacity: 1; }
56%, 100% { transform: translateY(0); opacity: 1; }
}
@keyframes ch-loop-dots {
0%, 47% { transform: translateY(10px); opacity: 0; }
48% { opacity: 1; }
60%, 68% { transform: translateY(0); opacity: 1; }
69%, 100% { transform: translateY(0); opacity: 0; }
}
@keyframes ch-loop-reply {
0%, 68% { transform: translateY(10px); opacity: 0; }
69% { opacity: 1; }
80%, 100% { transform: translateY(0); opacity: 1; }
}
.ch__loop-me,
.ch__loop-dots,
.ch__loop-reply {
justify-self: end;
display: none;
}
.ch__loop-me {
background: $stage-orange;
color: $subject-cream;
}
.ch__loop-dots,
.ch__loop-reply {
justify-self: start;
background: rgba(23, 20, 26, .07);
}
.ch.is-demo {
.ch__panel {
animation-name: ch-loop-panel;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ch__loop-me {
display: block;
animation-name: ch-loop-me;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ch__loop-dots {
display: block;
animation-name: ch-loop-dots;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ch__loop-reply {
display: block;
animation-name: ch-loop-reply;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
}
@media (max-width: 340px) {
.ch__panel { height: 112px; padding: $sp-1; }
.ch__bubble { font-size: 10px; }
.ch__input, .ch__send { height: 28px; }
.ch__send { width: 28px; font-size: 14px; }
.ch__fab { width: 40px; height: 40px; font-size: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.ch.is-demo .ch__panel,
.ch.is-demo .ch__loop-me,
.ch.is-demo .ch__loop-dots,
.ch.is-demo .ch__loop-reply { animation: none !important; transform: none !important; }
// 열리고 답까지 온 결과만 남긴다 — 채팅이 꽉 찬 모습으로 멈춘다
.ch.is-demo .ch__panel { opacity: 1 !important; pointer-events: auto !important; }
.ch.is-demo .ch__loop-me,
.ch.is-demo .ch__loop-reply { opacity: 1 !important; display: block !important; }
.ch.is-demo .ch__loop-dots { opacity: 0 !important; display: none !important; }
}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);
}
.ch {
position: relative;
display: grid;
justify-items: end;
align-content: end;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ch__panel {
box-sizing: border-box;
width: min(236px, 92vw);
height: 148px;
display: grid;
grid-template-rows: auto 1fr auto;
padding: 8px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(16px) scale(0.96);
opacity: 0;
pointer-events: none;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 140ms linear;
}
.ch__head {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.ch__name {
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.ch__log {
display: grid;
align-content: start;
gap: 4px;
overflow: hidden;
}
.ch__bubble {
margin: 0;
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
line-height: 1.45;
}
.ch__bubble--bot {
justify-self: start;
background: rgba(23, 20, 26, 0.07);
}
.ch__bubble--me {
justify-self: end;
background: #ff4d1f;
color: #fff7e6;
}
.ch__form {
display: flex;
gap: 4px;
margin-top: 4px;
}
.ch__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
height: 32px;
margin: 0;
padding: 0 8px;
border: 1px solid rgba(23, 20, 26, 0.22);
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.ch__input::placeholder {
color: rgba(23, 20, 26, 0.45);
}
.ch__input:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 1px;
}
.ch__send {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #ff4d1f;
color: #fff7e6;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.ch__send:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ch__fab {
display: inline-grid;
place-items: center;
width: 48px;
height: 48px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: #17141a;
color: #ff4d1f;
font-size: 24px;
line-height: 1;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: transform 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ch__fab:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 3px;
}
.ch__fab:active {
transform: scale(0.92);
}
.ch.is-open .ch__panel {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
@keyframes ch-loop-panel {
0%, 9% {
transform: translateY(16px) scale(0.96);
opacity: 0;
}
10% {
opacity: 1;
}
28%, 100% {
transform: translateY(0) scale(1);
opacity: 1;
}
}
@keyframes ch-loop-me {
0%, 42% {
transform: translateY(10px);
opacity: 0;
}
43% {
opacity: 1;
}
56%, 100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes ch-loop-dots {
0%, 47% {
transform: translateY(10px);
opacity: 0;
}
48% {
opacity: 1;
}
60%, 68% {
transform: translateY(0);
opacity: 1;
}
69%, 100% {
transform: translateY(0);
opacity: 0;
}
}
@keyframes ch-loop-reply {
0%, 68% {
transform: translateY(10px);
opacity: 0;
}
69% {
opacity: 1;
}
80%, 100% {
transform: translateY(0);
opacity: 1;
}
}
.ch__loop-me,
.ch__loop-dots,
.ch__loop-reply {
justify-self: end;
display: none;
}
.ch__loop-me {
background: #ff4d1f;
color: #fff7e6;
}
.ch__loop-dots,
.ch__loop-reply {
justify-self: start;
background: rgba(23, 20, 26, 0.07);
}
.ch.is-demo .ch__panel {
animation-name: ch-loop-panel;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ch.is-demo .ch__loop-me {
display: block;
animation-name: ch-loop-me;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ch.is-demo .ch__loop-dots {
display: block;
animation-name: ch-loop-dots;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ch.is-demo .ch__loop-reply {
display: block;
animation-name: ch-loop-reply;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@media (max-width: 340px) {
.ch__panel {
height: 112px;
padding: 4px;
}
.ch__bubble {
font-size: 10px;
}
.ch__input, .ch__send {
height: 28px;
}
.ch__send {
width: 28px;
font-size: 14px;
}
.ch__fab {
width: 40px;
height: 40px;
font-size: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.ch.is-demo .ch__panel,
.ch.is-demo .ch__loop-me,
.ch.is-demo .ch__loop-dots,
.ch.is-demo .ch__loop-reply {
animation: none !important;
transform: none !important;
}
.ch.is-demo .ch__panel {
opacity: 1 !important;
pointer-events: auto !important;
}
.ch.is-demo .ch__loop-me,
.ch.is-demo .ch__loop-reply {
opacity: 1 !important;
display: block !important;
}
.ch.is-demo .ch__loop-dots {
opacity: 0 !important;
display: none !important;
}
}// 도움말 FAB: 채팅 창이 열리고 보낸 말은 진짜로 쌓인다
var ch = document.getElementById('ch');
var chFab = document.getElementById('ch-fab');
var chForm = document.getElementById('ch-form');
var chInput = document.getElementById('ch-input');
var chLog = document.getElementById('ch-log');
function chAdd(text, who) {
var p = document.createElement('p');
p.className = 'ch__bubble ch__bubble--' + who;
p.textContent = text;
chLog.appendChild(p);
return p;
}
chFab.addEventListener('click', function () {
ch.classList.remove('is-demo');
ch.classList.toggle('is-open');
var open = ch.classList.contains('is-open');
chFab.setAttribute('aria-expanded', open ? 'true' : 'false');
chFab.setAttribute('aria-label', open ? '도움말 채팅 닫기' : '도움말 채팅 열기');
if (open) window.setTimeout(function () { chInput.focus({ preventScroll: true }); }, 240);
});
chForm.addEventListener('submit', function (e) {
e.preventDefault();
var text = chInput.value.trim();
if (!text) return;
ch.classList.remove('is-demo');
chAdd(text, 'me');
chInput.value = '';
var dots = chAdd('...', 'bot');
window.setTimeout(function () {
dots.textContent = '접수했습니다. 잠시만 기다려 주세요.';
}, 900);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./ChatHelpFab.module.scss";
export type ChatHelpFabProps = {
panelLabel?: string;
headName?: string;
greeting?: string;
placeholder?: string;
inputLabel?: string;
sendLabel?: string;
openLabel?: string;
closeLabel?: string;
groupLabel?: string;
/** 격자 루프에서만 보이는 보기용 대화 */
loopAsk?: string;
loopDots?: string;
loopReply?: string;
/** 사람이 보낸 말에 붙는 자동 응답 */
replyText?: string;
replyDelay?: number;
panelId?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 에 FormEvent 가 없다 — 쓰는 것만 로컬로 선언한다
type SubmitEvt = { preventDefault(): void };
type Line = { id: number; text: string; me: boolean };
function Send() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M227.91,134.86,59.93,231a8,8,0,0,1-11.44-9.67L80,128,48.49,34.72a8,8,0,0,1,11.44-9.67l168,95.85A8,8,0,0,1,227.91,134.86Z" opacity=".2" />
<path d="M231.87,114l-168-95.89A16,16,0,0,0,40.92,37.34L71.55,128,40.92,218.67A16,16,0,0,0,56,240a16.15,16.15,0,0,0,7.93-2.1l167.92-96.05a16,16,0,0,0,.05-27.89ZM56,224a.56.56,0,0,0,0-.12L85.74,136H144a8,8,0,0,0,0-16H85.74L56.06,32.16A.46.46,0,0,0,56,32l168,95.83Z" />
</svg>
);
}
function Chat() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" 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=".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>
);
}
export default function ChatHelpFab({
panelLabel = "Help chat",
headName = "How can we help?",
greeting = "Tell us what you need. A human answers on weekday afternoons.",
placeholder = "Write a message",
inputLabel = "Message",
sendLabel = "Send message",
openLabel = "Open help chat",
closeLabel = "Close help chat",
groupLabel = "Help button that opens a chat panel",
loopAsk = "When does it ship?",
loopDots = "...",
loopReply = "It leaves first thing tomorrow morning.",
replyText = "Got it. Hang on a moment.",
replyDelay = 900,
panelId = "ch-panel",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ff4d1f",
}: ChatHelpFabProps) {
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
const [draft, setDraft] = useState("");
const [lines, setLines] = useState<Line[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const focusT = useRef<number>(null);
const replyT = useRef<number>(null);
const seq = useRef<number>(0);
useEffect(() => () => {
if (focusT.current !== null) window.clearTimeout(focusT.current);
if (replyT.current !== null) window.clearTimeout(replyT.current);
}, []);
function toggle() {
setLive(false);
const next = !open;
setOpen(next);
if (focusT.current !== null) window.clearTimeout(focusT.current);
if (!next) return;
// 판이 다 올라온 뒤에 커서를 넣는다
focusT.current = window.setTimeout(() => {
const el = inputRef.current;
if (el) el.focus({ preventScroll: true });
}, duration);
}
// 보낸 말은 진짜로 쌓인다 — 먼저 "..." 이 붙고 잠시 뒤 그 말풍선이 답으로 바뀐다
function send(e: SubmitEvt) {
e.preventDefault();
const text = draft.trim();
if (!text) return;
setLive(false);
const mineId = (seq.current === null ? 0 : seq.current) + 1;
const botId = mineId + 1;
seq.current = botId;
setLines((prev) => prev.concat([
{ id: mineId, text: text, me: true },
{ id: botId, text: loopDots, me: false },
]));
setDraft("");
if (replyT.current !== null) window.clearTimeout(replyT.current);
replyT.current = window.setTimeout(() => {
setLines((prev) => prev.map((l) => (l.id === botId ? { id: l.id, text: replyText, me: false } : l)));
}, replyDelay);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.ch, live ? styles.isDemo : "", open ? styles.isOpen : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.panel} id={panelId} role="dialog" aria-label={panelLabel} aria-modal="false">
<header className={styles.head}>
<span className={styles.name}>{headName}</span>
</header>
<div className={styles.log} aria-live="polite">
<p className={[styles.bubble, styles.bubbleBot].join(" ")}>{greeting}</p>
<p className={[styles.bubble, styles.loopMe].join(" ")} aria-hidden="true">{loopAsk}</p>
<p className={[styles.bubble, styles.loopDots].join(" ")} aria-hidden="true">{loopDots}</p>
<p className={[styles.bubble, styles.loopReply].join(" ")} aria-hidden="true">{loopReply}</p>
{lines.map((l) => (
<p key={l.id} className={[styles.bubble, l.me ? styles.bubbleMe : styles.bubbleBot].join(" ")}>{l.text}</p>
))}
</div>
<form className={styles.form} onSubmit={send}>
<input
className={styles.input}
ref={inputRef}
type="text"
value={draft}
placeholder={placeholder}
aria-label={inputLabel}
onChange={(e: ChangeEvent<HTMLInputElement>) => setDraft(e.target.value)}
/>
<button className={styles.send} type="submit" aria-label={sendLabel}>
<span aria-hidden="true"><Send /></span>
</button>
</form>
</div>
<button
className={styles.fab}
type="button"
aria-expanded={open}
aria-controls={panelId}
aria-label={open ? closeLabel : openLabel}
onClick={toggle}
>
<span aria-hidden="true"><Chat /></span>
</button>
</section>
);
}08 누르면 채팅 창이 열리는 도움말 버튼 — 측정 누적면적 27.706% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 플로팅 버튼 아이콘 버튼 패턴 9종 — 누르고 끌고 펼쳐지는


