09今日のまとめ共有カード
今日の記録を canvas に描いてカード1枚にし、ボタン1回でその画像をクリップボードへ送ります。クリップボードが塞がれた環境では toDataURL のリンクで代わりに保存します。
使いどころ記録の共有ボタン、結果画面の画像書き出し
canvasgetContexttoDataURLnavigator.clipboard
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-share-network" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 200a32 32 0 1 1-32-32a32 32 0 0 1 32 32M176 88a32 32 0 1 0-32-32a32 32 0 0 0 32 32" opacity=".2"/><path d="M176 160a39.9 39.9 0 0 0-28.62 12.09l-46.1-29.63a39.8 39.8 0 0 0 0-28.92l46.1-29.63a40 40 0 1 0-8.66-13.45l-46.1 29.63a40 40 0 1 0 0 55.82l46.1 29.63A40 40 0 1 0 176 160m0-128a24 24 0 1 1-24 24a24 24 0 0 1 24-24M64 152a24 24 0 1 1 24-24a24 24 0 0 1-24 24m112 72a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="sh is-demo" id="sh">
<p class="sh__title">
<svg class="sh__ico" aria-hidden="true"><use href="#ico-share-network"/></svg>
<span>今日のまとめカード</span>
</p>
<div class="sh__main">
<span class="sh__cardBox">
<canvas class="sh__canvas" id="card" width="400" height="224" role="img" aria-label="今日のまとめカードの画像"></canvas>
<span class="sh__toast" id="toast" aria-hidden="true">コピーしました</span>
</span>
<div class="sh__side">
<button class="sh__go" id="copy" type="button">画像としてコピー</button>
<button class="sh__more" id="more" type="button">1日追加</button>
<p class="sh__say" id="say" role="status" aria-live="polite">カードを作りました</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 오늘 요약 공유 카드 — 카드는 DOM 이 아니라 canvas 에 그린다. 페이지가 열릴 때 한 번 그려 두므로
// 버튼을 누르기 전에도, 격자 미리보기에서도 무엇을 공유하는지 그대로 보인다.
// 복사는 canvas.toBlob → navigator.clipboard.write 이고, 막힌 환경에서는 toDataURL 링크로 내려받는다.
// 미리보기(.is-demo)는 카드가 한 번 떠올랐다 앉는 움직임과 알림 띠 컷을 겹친다 —
// 캔버스 그리기 자체는 렌더러의 애니메이션 시계로 움직이지 않으므로 움직임은 CSS 가 맡는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.sh { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.sh__title {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(12px, 3.2vw, 14px); color: $color;
}
.sh__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.sh__main { display: flex; align-items: center; gap: $sp-4; }
.sh__cardBox { position: relative; display: block; flex: 0 0 auto; }
.sh__canvas {
display: block; width: 200px; height: 112px; border-radius: $r-card;
box-shadow: $shadow-raised;
}
.sh__toast {
position: absolute; left: 50%; bottom: $sp-2; transform: translate(-50%, 0);
border-radius: $r-pill; padding: $sp-1 $sp-3; background: $subject-cream; color: $color;
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.6; white-space: nowrap;
opacity: 0;
}
.sh__toast.is-on { opacity: 1; }
.sh__side { display: flex; flex-direction: column; align-items: flex-start; gap: $sp-2; flex: 1 1 auto; min-width: 0; }
.sh__go, .sh__more {
border-radius: $r-pill; padding: $sp-1 $sp-3; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1.6;
}
.sh__go { border: 0; background: $color; color: $stage-yellow; }
.sh__more { border: 2px solid rgba($color, .5); background: transparent; color: $color; }
.sh__go:focus-visible, .sh__more:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sh__say { margin: 0; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); color: rgba($color, .68); }
// ── 격자 미리보기 ───────────────────────────────────────────────
.sh.is-demo .sh__canvas {
animation-name: shLift; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes shLift {
0% { transform: translateY(0) scale(1); }
26% { transform: translateY(-14px) scale(1.04); }
62% { transform: translateY(-14px) scale(1.04); }
86% { transform: translateY(0) scale(1); }
100% { transform: translateY(0) scale(1); }
}
// 알림 띠는 반투명 겹침이 안 생기게 한 프레임에 컷으로 들어오고 나간다
.sh.is-demo .sh__toast {
animation-name: shToast; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes shToast {
0%, 30% { opacity: 0; transform: translate(-50%, 8px); }
34% { opacity: 1; transform: translate(-50%, 0); }
72% { opacity: 1; transform: translate(-50%, 0); }
80% { opacity: 0; transform: translate(-50%, 8px); }
100% { opacity: 0; transform: translate(-50%, 8px); }
}
@media (max-width: 340px) {
.sh { gap: $sp-2; }
.sh__main { gap: $sp-2; }
.sh__canvas { width: 148px; height: 83px; }
.sh__side { gap: $sp-1; }
}
// reduce 에서는 떠오름과 알림 띠를 멈추고 카드 한 장만 보여 준다
@media (prefers-reduced-motion: reduce) {
.sh.is-demo .sh__canvas { animation: none !important; transform: none !important; }
.sh.is-demo .sh__toast { animation: none !important; opacity: 0 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sh {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.sh__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(12px, 3.2vw, 14px);
color: #17141a;
}
.sh__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.sh__main {
display: flex;
align-items: center;
gap: 16px;
}
.sh__cardBox {
position: relative;
display: block;
flex: 0 0 auto;
}
.sh__canvas {
display: block;
width: 200px;
height: 112px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sh__toast {
position: absolute;
left: 50%;
bottom: 8px;
transform: translate(-50%, 0);
border-radius: 999px;
padding: 4px 12px;
background: #fff7e6;
color: #17141a;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.6;
white-space: nowrap;
opacity: 0;
}
.sh__toast.is-on {
opacity: 1;
}
.sh__side {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
}
.sh__go, .sh__more {
border-radius: 999px;
padding: 4px 12px;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1.6;
}
.sh__go {
border: 0;
background: #17141a;
color: #ffd23f;
}
.sh__more {
border: 2px solid rgba(23, 20, 26, 0.5);
background: transparent;
color: #17141a;
}
.sh__go:focus-visible, .sh__more:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.sh__say {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.68);
}
.sh.is-demo .sh__canvas {
animation-name: shLift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes shLift {
0% {
transform: translateY(0) scale(1);
}
26% {
transform: translateY(-14px) scale(1.04);
}
62% {
transform: translateY(-14px) scale(1.04);
}
86% {
transform: translateY(0) scale(1);
}
100% {
transform: translateY(0) scale(1);
}
}
.sh.is-demo .sh__toast {
animation-name: shToast;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes shToast {
0%, 30% {
opacity: 0;
transform: translate(-50%, 8px);
}
34% {
opacity: 1;
transform: translate(-50%, 0);
}
72% {
opacity: 1;
transform: translate(-50%, 0);
}
80% {
opacity: 0;
transform: translate(-50%, 8px);
}
100% {
opacity: 0;
transform: translate(-50%, 8px);
}
}
@media (max-width: 340px) {
.sh {
gap: 8px;
}
.sh__main {
gap: 8px;
}
.sh__canvas {
width: 148px;
height: 83px;
}
.sh__side {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.sh.is-demo .sh__canvas {
animation: none !important;
transform: none !important;
}
.sh.is-demo .sh__toast {
animation: none !important;
opacity: 0 !important;
}
}var STORE = 'godrich-habit-card';
var HEAD_TEXT = '今日の習慣まとめ';
var STREAK_TEXT = '{n}日連続';
var DONE_TEXT = '完了 {c}/{t}';
var COPIED_TEXT = 'コピーしました';
var SAVED_TEXT = '画像として保存しました';
var MADE_TEXT = 'カードを作りました';
var root = document.getElementById('sh');
var canvas = document.getElementById('card');
var ctx = canvas.getContext('2d');
var toast = document.getElementById('toast');
var sayEl = document.getElementById('say');
var state = { streak: 12, done: 5, total: 6 };
var toastTimer = 0;
function load() {
try {
var raw = window.localStorage.getItem(STORE);
if (raw) { state = JSON.parse(raw); }
} catch (e) { /* 저장소가 막힌 창에서는 기본값 그대로 */ }
}
function save() {
try { window.localStorage.setItem(STORE, JSON.stringify(state)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
// 카드는 페이지가 열릴 때 한 번 그려 둔다 — 버튼을 누르기 전에도 무엇을 공유하는지 보여야 한다
function draw() {
var d = new Date();
ctx.clearRect(0, 0, 400, 224);
ctx.fillStyle = '#17141a';
roundRect(0, 0, 400, 224, 24);
ctx.fill();
ctx.fillStyle = '#ffd23f';
roundRect(24, 24, 8, 40, 4);
ctx.fill();
ctx.fillStyle = '#fff7e6';
ctx.textBaseline = 'top';
ctx.font = '700 20px sans-serif';
ctx.fillText(HEAD_TEXT, 44, 30);
ctx.fillStyle = '#ffd23f';
ctx.font = '800 58px sans-serif';
ctx.fillText(STREAK_TEXT.replace('{n}', String(state.streak)), 40, 86);
ctx.fillStyle = '#fff7e6';
ctx.font = '700 20px sans-serif';
ctx.fillText(DONE_TEXT.replace('{c}', String(state.done)).replace('{t}', String(state.total)), 40, 164);
ctx.fillStyle = 'rgba(255, 247, 230, 0.55)';
ctx.font = '700 18px sans-serif';
ctx.fillText((d.getMonth() + 1) + '.' + d.getDate(), 320, 166);
}
function flash(text) {
sayEl.textContent = text;
toast.textContent = text;
toast.classList.add('is-on');
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(function () { toast.classList.remove('is-on'); }, 1400);
}
// 클립보드 이미지 쓰기가 막힌 환경(사파리 구버전·권한 거부)에서는 toDataURL 링크로 내려받는다
function copyCard() {
if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write && canvas.toBlob) {
canvas.toBlob(function (blob) {
var item = new window.ClipboardItem({ 'image/png': blob });
navigator.clipboard.write([item]).then(function () { flash(COPIED_TEXT); }, download);
}, 'image/png');
return;
}
download();
}
function download() {
var a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = 'habit-card.png';
a.click();
flash(SAVED_TEXT);
}
load();
draw();
sayEl.textContent = MADE_TEXT;
document.getElementById('copy').addEventListener('click', function () {
root.classList.remove('is-demo');
copyCard();
});
document.getElementById('more').addEventListener('click', function () {
root.classList.remove('is-demo');
state.streak = state.streak + 1;
state.done = state.done >= state.total ? 1 : state.done + 1;
save();
draw();
flash(MADE_TEXT);
});// ShareCard — vanilla 판(run/297/demo/09-habit-share-card/index.html) 의 React 포트.
// 카드는 DOM 이 아니라 canvas 에 그린다. 값·수치·그리는 좌표는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShareCard.module.scss";
export type ShareCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type State = { streak: number; done: number; total: number };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
const STORE = "godrich-habit-card";
const TITLE = "오늘 요약 카드";
const CANVAS_LABEL = "오늘 요약 카드 이미지";
const HEAD_TEXT = "오늘의 습관 요약";
const STREAK_TEXT = "{n}일 연속";
const DONE_TEXT = "완료 {c}/{t}";
const COPY_TEXT = "이미지로 복사";
const MORE_TEXT = "하루 더 채우기";
const COPIED_TEXT = "복사했습니다";
const SAVED_TEXT = "이미지로 내려받았습니다";
const MADE_TEXT = "카드를 만들었습니다";
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
export default function ShareCard({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: ShareCardProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const timerRef = useRef<number>(0) as Box<number>;
const [state, setState] = useState<State>({ streak: 12, done: 5, total: 6 });
const [say, setSay] = useState(MADE_TEXT);
const [toast, setToast] = useState("");
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) { setState(JSON.parse(raw) as State); }
} catch (e) { /* 저장소가 막힌 창에서는 기본값 그대로 */ }
return () => { window.clearTimeout(timerRef.current); };
}, []);
// 카드는 상태가 바뀔 때마다 다시 그린다 — 버튼을 누르기 전에도 무엇을 공유하는지 보여야 한다
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) { return; }
const ctx = canvas.getContext("2d");
if (!ctx) { return; }
const d = new Date();
ctx.clearRect(0, 0, 400, 224);
ctx.fillStyle = "#17141a";
roundRect(ctx, 0, 0, 400, 224, 24);
ctx.fill();
ctx.fillStyle = "#ffd23f";
roundRect(ctx, 24, 24, 8, 40, 4);
ctx.fill();
ctx.fillStyle = "#fff7e6";
ctx.textBaseline = "top";
ctx.font = "700 20px sans-serif";
ctx.fillText(HEAD_TEXT, 44, 30);
ctx.fillStyle = "#ffd23f";
ctx.font = "800 58px sans-serif";
ctx.fillText(STREAK_TEXT.replace("{n}", String(state.streak)), 40, 86);
ctx.fillStyle = "#fff7e6";
ctx.font = "700 20px sans-serif";
ctx.fillText(DONE_TEXT.replace("{c}", String(state.done)).replace("{t}", String(state.total)), 40, 164);
ctx.fillStyle = "rgba(255, 247, 230, 0.55)";
ctx.font = "700 18px sans-serif";
ctx.fillText(`${d.getMonth() + 1}.${d.getDate()}`, 320, 166);
}, [state]);
function flash(text: string) {
setSay(text);
setToast(text);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setToast(""), 1400);
}
// 클립보드 이미지 쓰기가 막힌 환경에서는 toDataURL 링크로 내려받는다
function download() {
const canvas = canvasRef.current;
if (!canvas) { return; }
const a = document.createElement("a");
a.href = canvas.toDataURL("image/png");
a.download = "habit-card.png";
a.click();
flash(SAVED_TEXT);
}
function copyCard() {
setDemo(false);
const canvas = canvasRef.current;
if (!canvas) { return; }
const holder = window as unknown as { ClipboardItem?: new (v: { [k: string]: Blob }) => ClipboardItem };
if (holder.ClipboardItem && navigator.clipboard && navigator.clipboard.write && canvas.toBlob) {
canvas.toBlob((blob) => {
if (!blob || !holder.ClipboardItem) { download(); return; }
const item = new holder.ClipboardItem({ "image/png": blob });
navigator.clipboard.write([item]).then(() => flash(COPIED_TEXT), download);
}, "image/png");
return;
}
download();
}
function addDay() {
setDemo(false);
setState({
streak: state.streak + 1,
done: state.done >= state.total ? 1 : state.done + 1,
total: state.total,
});
flash(MADE_TEXT);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const toastCls = [styles.toast, toast ? styles.toastOn : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-share-network" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 200a32 32 0 1 1-32-32a32 32 0 0 1 32 32M176 88a32 32 0 1 0-32-32a32 32 0 0 0 32 32" opacity=".2"/><path d="M176 160a39.9 39.9 0 0 0-28.62 12.09l-46.1-29.63a39.8 39.8 0 0 0 0-28.92l46.1-29.63a40 40 0 1 0-8.66-13.45l-46.1 29.63a40 40 0 1 0 0 55.82l46.1 29.63A40 40 0 1 0 176 160m0-128a24 24 0 1 1-24 24a24 24 0 0 1 24-24M64 152a24 24 0 1 1 24-24a24 24 0 0 1-24 24m112 72a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.title}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-share-network" /></svg>
<span>{TITLE}</span>
</p>
<div className={styles.main}>
<span className={styles.cardBox}>
<canvas
className={styles.canvas}
ref={canvasRef}
width={400}
height={224}
role="img"
aria-label={CANVAS_LABEL}
/>
<span className={toastCls} aria-hidden="true">{toast}</span>
</span>
<div className={styles.side}>
<button className={styles.go} type="button" onClick={copyCard}>{COPY_TEXT}</button>
<button className={styles.more} type="button" onClick={addDay}>{MORE_TEXT}</button>
<p className={styles.say} role="status" aria-live="polite">{say}</p>
</div>
</div>
</div>
</main>
);
}09 今日のまとめ共有カード — 計測 累積面積 12.913% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · 習慣トラッカー UI 9選 — 草グリッドから共有カードまで

