07保存を知らせるトーストのスライド
保存が終わると画面下から短い通知帯がスライドして現れ、少し留まってから下に消える。
使いどころモバイルアプリ画面の保存完了通知
TOAST_ENTER_MSTOAST_HOLD_MSshowToast
<main class="stage">
<div class="ts is-demo" id="ts">
<p class="ts__caption">保存完了の通知</p>
<div class="ts__scene" id="tsScene">
<button class="ts__btn" id="tsBtn" type="button" aria-pressed="false">
<svg class="ts__icon" viewBox="0 0 24 24" width="30" height="30" aria-hidden="true" focusable="false">
<path class="ts__shape" d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z"/>
</svg>
</button>
<div class="ts__toast" id="tsToast" role="status" aria-live="polite">
<span id="tsToastText">保存しました</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 토스트가 뜬 뒤 경과 시간(rAF dt 누적) → 진입/유지/퇴장 구간과 그 진행률(위치·투명도).
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
.ts {
display: grid;
gap: $sp-3;
justify-items: center;
}
.ts__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-cream;
}
.ts__scene {
position: relative;
width: 260px;
height: 150px;
display: grid;
place-items: center;
overflow: hidden;
}
.ts__btn {
border: 0;
background: $stage-paper;
border-radius: $r-pill;
padding: $sp-4;
cursor: pointer;
color: $color;
}
.ts__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.ts__shape {
fill: none;
stroke: $color;
stroke-width: 1.6;
stroke-linejoin: round;
}
.ts__btn[aria-pressed="true"] .ts__shape {
fill: $color;
}
.ts__toast {
position: absolute;
bottom: $sp-6;
left: $sp-5;
right: $sp-5;
padding: $sp-3 $sp-5;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
text-align: center;
box-shadow: $shadow-raised;
will-change: transform, opacity;
}
@media (max-width: 340px) {
.ts__scene { width: 220px; height: 130px; }
}
@media (prefers-reduced-motion: reduce) {
.ts__toast { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ts {
display: grid;
gap: 12px;
justify-items: center;
}
.ts__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #fff7e6;
}
.ts__scene {
position: relative;
width: 260px;
height: 150px;
display: grid;
place-items: center;
overflow: hidden;
}
.ts__btn {
border: 0;
background: #ffffff;
border-radius: 999px;
padding: 16px;
cursor: pointer;
color: #0a4fe8;
}
.ts__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.ts__shape {
fill: none;
stroke: #0a4fe8;
stroke-width: 1.6;
stroke-linejoin: round;
}
.ts__btn[aria-pressed=true] .ts__shape {
fill: #0a4fe8;
}
.ts__toast {
position: absolute;
bottom: 24px;
left: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
text-align: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
will-change: transform, opacity;
}
@media (max-width: 340px) {
.ts__scene {
width: 220px;
height: 130px;
}
}
@media (prefers-reduced-motion: reduce) {
.ts__toast {
transition: none;
}
}(function () {
"use strict";
var TOAST_ENTER_MS = 180;
var TOAST_HOLD_MS = 520;
var TOAST_EXIT_MS = 180;
var CYCLE_S = 2;
var root = document.getElementById("ts");
var btn = document.getElementById("tsBtn");
var toast = document.getElementById("tsToast");
var toastText = document.getElementById("tsToastText");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var saved = false;
var toastStart = null;
var pendingMsg = null;
function showToast(now, msg) {
toastText.textContent = msg;
toastStart = now;
}
// 값 하나: 토스트가 뜬 뒤 경과 시간(rAF dt 누적) → 진입/유지/퇴장 세 구간 중 어디인지, 그 구간의 진행률.
function render(now) {
if (toastStart === null) {
toast.style.transform = "translateY(120%)";
toast.style.opacity = "0";
return;
}
var elapsed = now - toastStart;
var enterMs = reduce ? 1 : TOAST_ENTER_MS;
var exitMs = reduce ? 1 : TOAST_EXIT_MS;
var enterEnd = enterMs;
var holdEnd = enterEnd + TOAST_HOLD_MS;
var exitEnd = holdEnd + exitMs;
var y, op;
if (elapsed < enterEnd) {
var p = elapsed / enterEnd;
var eased = 1 - Math.pow(1 - p, 3);
y = 120 - eased * 120;
op = eased;
} else if (elapsed < holdEnd) {
y = 0; op = 1;
} else if (elapsed < exitEnd) {
var q = (elapsed - holdEnd) / exitMs;
var easedQ = Math.pow(q, 2);
y = easedQ * 120;
op = 1 - easedQ;
} else {
y = 120; op = 0;
toastStart = null;
}
toast.style.transform = "translateY(" + y.toFixed(2) + "%)";
toast.style.opacity = op.toFixed(3);
}
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
saved = !saved;
btn.setAttribute("aria-pressed", String(saved));
pendingMsg = saved ? "保存しました" : "保存を取り消しました";
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) start = now;
if (pendingMsg) { showToast(now, pendingMsg); pendingMsg = null; }
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && root.classList.contains("is-demo")) {
if (!toggledA && t > 0.05) {
toggledA = true; saved = true; btn.setAttribute("aria-pressed", "true");
showToast(now, "保存しました");
}
if (!toggledB && t > 1.05) {
toggledB = true; saved = false; btn.setAttribute("aria-pressed", "false");
showToast(now, "保存を取り消しました");
}
}
render(now);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// ToastConfirmSlideBookmark — vanilla 판(run/416/demo/07-bookmark-btn-toast-confirm-slide/index.html) 의 React 포트.
// 값 하나: 토스트가 뜬 뒤 경과 시간(rAF now 기준) → 진입/유지/퇴장 세 구간 중 어디인지, 그 구간의 진행률.
import { useEffect, useRef, useState } from "react";
import styles from "./ToastConfirmSlideBookmark.module.scss";
type Box<T> = { current: T };
const TOAST_ENTER_MS = 180;
const TOAST_HOLD_MS = 520;
const TOAST_EXIT_MS = 180;
const CYCLE_S = 2;
export default function ToastConfirmSlideBookmark() {
const toastRef = useRef<HTMLDivElement | null>(null);
const toastTextRef = useRef<HTMLSpanElement | null>(null);
const toastStartRef = useRef<number | null>(null);
const pendingMsgRef = useRef<string | null>(null);
const frameRef = useRef<number>(0) as Box<number>;
const [pressed, setPressed] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function showToast(now: number, msg: string): void {
if (toastTextRef.current) toastTextRef.current.textContent = msg;
toastStartRef.current = now;
}
function render(now: number): void {
const toast = toastRef.current;
if (!toast) return;
if (toastStartRef.current === null) {
toast.style.transform = "translateY(120%)";
toast.style.opacity = "0";
return;
}
const elapsed = now - toastStartRef.current;
const enterMs = reduce ? 1 : TOAST_ENTER_MS;
const exitMs = reduce ? 1 : TOAST_EXIT_MS;
const enterEnd = enterMs;
const holdEnd = enterEnd + TOAST_HOLD_MS;
const exitEnd = holdEnd + exitMs;
let y: number;
let op: number;
if (elapsed < enterEnd) {
const p = elapsed / enterEnd;
const eased = 1 - Math.pow(1 - p, 3);
y = 120 - eased * 120;
op = eased;
} else if (elapsed < holdEnd) {
y = 0; op = 1;
} else if (elapsed < exitEnd) {
const q = (elapsed - holdEnd) / exitMs;
const easedQ = Math.pow(q, 2);
y = easedQ * 120;
op = 1 - easedQ;
} else {
y = 120; op = 0;
toastStartRef.current = null;
}
toast.style.transform = "translateY(" + y.toFixed(2) + "%)";
toast.style.opacity = op.toFixed(3);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) start = now;
if (pendingMsgRef.current) { showToast(now, pendingMsgRef.current); pendingMsgRef.current = null; }
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && demo) {
if (!toggledA && t > 0.05) {
toggledA = true; setPressed(true);
showToast(now, "저장했어요");
}
if (!toggledB && t > 1.05) {
toggledB = true; setPressed(false);
showToast(now, "저장을 취소했어요");
}
}
render(now);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
const v = !pressed;
setPressed(v);
pendingMsgRef.current = v ? "저장했어요" : "저장을 취소했어요";
}
return (
<main className={styles.stage}>
<div className={styles.ts}>
<p className={styles.caption}>저장 완료 알림</p>
<div className={styles.scene}>
<button className={styles.btn} type="button" aria-pressed={pressed} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={30} height={30} aria-hidden="true" focusable="false">
<path className={styles.shape} d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z" />
</svg>
</button>
<div className={styles.toast} ref={toastRef} role="status" aria-live="polite">
<span ref={toastTextRef}>저장했어요</span>
</div>
</div>
</div>
</main>
);
}07 保存を知らせるトーストのスライド — 計測 累積面積 10.493% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · ブックマーク ボタン html 実装 9パターン


