08여섯 자리 위치 전환
위·아래 세 줄씩 여섯 자리 중 하나를 고르면 알림 묶음이 그 모서리로 옮겨 붙고, 들어오는 방향과 쌓이는 순서도 그 자리에 맞춰 뒤집힌다.
쓰임화면 오른쪽 아래에 채팅 위젯이 이미 있는 서비스
data-posinsetaria-pressed

<main class="stage">
<div class="ps is-demo">
<div class="ps__screen" id="ps-screen" data-pos="br">
<span class="ps__bar" aria-hidden="true"><i></i><i></i><i></i></span>
<div class="ps__toasts" id="ps-toasts" role="region" aria-label="알림이 뜨는 자리">
<span class="ps__toast">
<span class="ps__dot" aria-hidden="true"></span>
<span class="ps__msg">링크 복사됨</span>
</span>
<span class="ps__toast">
<span class="ps__dot" aria-hidden="true"></span>
<span class="ps__msg">초안 저장됨</span>
</span>
</div>
</div>
<div class="ps__pads" role="group" aria-label="알림 자리 고르기">
<button class="ps__pad" type="button" data-pos="tl" aria-pressed="false" aria-label="왼쪽 위"><span></span></button>
<button class="ps__pad" type="button" data-pos="tc" aria-pressed="false" aria-label="가운데 위"><span></span></button>
<button class="ps__pad" type="button" data-pos="tr" aria-pressed="false" aria-label="오른쪽 위"><span></span></button>
<button class="ps__pad" type="button" data-pos="bl" aria-pressed="false" aria-label="왼쪽 아래"><span></span></button>
<button class="ps__pad" type="button" data-pos="bc" aria-pressed="false" aria-label="가운데 아래"><span></span></button>
<button class="ps__pad" type="button" data-pos="br" aria-pressed="true" aria-label="오른쪽 아래"><span></span></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.ps {
box-sizing: border-box;
width: min(376px, 88vw);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
// 가짜 브라우저 창 — 알림이 어느 구석에 붙는지 보여 주는 기준틀이다
.ps__screen {
position: relative;
box-sizing: border-box;
width: 100%;
height: 136px;
overflow: hidden;
border: 2px solid rgba(23, 20, 26, .16);
border-radius: $r-card;
background: $subject-cream;
}
.ps__bar {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
gap: $sp-1;
padding: $sp-2 $sp-3;
background: rgba(23, 20, 26, .06);
}
.ps__bar i {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(23, 20, 26, .22);
}
.ps__toasts {
position: absolute;
inset: calc(100% - 8px) auto auto calc(100% - 8px);
display: flex;
flex-direction: column;
gap: $sp-1;
width: 148px;
transform: translate(-100%, -100%);
transition: inset $duration $easing, transform $duration $easing;
}
.ps__screen[data-pos="tl"] .ps__toasts { inset: 30px auto auto 8px; transform: translate(0, 0); }
.ps__screen[data-pos="tc"] .ps__toasts { inset: 30px auto auto 50%; transform: translate(-50%, 0); }
.ps__screen[data-pos="tr"] .ps__toasts { inset: 30px auto auto calc(100% - 8px); transform: translate(-100%, 0); }
.ps__screen[data-pos="bl"] .ps__toasts { inset: calc(100% - 8px) auto auto 8px; transform: translate(0, -100%); }
.ps__screen[data-pos="bc"] .ps__toasts { inset: calc(100% - 8px) auto auto 50%; transform: translate(-50%, -100%); }
.ps__screen[data-pos="br"] .ps__toasts { inset: calc(100% - 8px) auto auto calc(100% - 8px); transform: translate(-100%, -100%); }
.ps__toast {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: $subject-ink;
color: $subject-cream;
box-shadow: $shadow-press;
}
.ps__dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: $color;
}
.ps__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ps__pads {
display: grid;
grid-template-columns: repeat(3, 40px);
gap: $sp-2;
}
.ps__pad {
box-sizing: border-box;
display: flex;
margin: 0;
padding: $sp-1;
height: 24px;
border: 2px solid rgba(23, 20, 26, .2);
border-radius: $r-sm;
background: transparent;
cursor: pointer;
}
.ps__pad span {
display: block;
width: 14px;
height: 6px;
border-radius: $r-xs;
background: $color;
}
.ps__pad[data-pos="tl"] span,
.ps__pad[data-pos="bl"] span { margin-right: auto; }
.ps__pad[data-pos="tc"] span,
.ps__pad[data-pos="bc"] span { margin: 0 auto; }
.ps__pad[data-pos="tr"] span,
.ps__pad[data-pos="br"] span { margin-left: auto; }
.ps__pad[data-pos="tl"],
.ps__pad[data-pos="tc"],
.ps__pad[data-pos="tr"] { align-items: flex-start; }
.ps__pad[data-pos="bl"],
.ps__pad[data-pos="bc"],
.ps__pad[data-pos="br"] { align-items: flex-end; }
.ps__pad[aria-pressed="true"] { border-color: $subject-ink; background: $subject-ink; }
.ps__pad:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 자동 루프: 여섯 자리를 차례로 돈다. 묶음은 늘 화면 안에 있고 자리만 옮겨 간다 ──
.ps.is-demo .ps__toasts {
transition: none;
animation-name: ps-move;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ps-move {
0%, 10% { inset: 30px auto auto 8px; transform: translate(0, 0); }
16.67%, 27% { inset: 30px auto auto 50%; transform: translate(-50%, 0); }
33.34%, 44% { inset: 30px auto auto calc(100% - 8px); transform: translate(-100%, 0); }
50%, 61% { inset: calc(100% - 8px) auto auto calc(100% - 8px); transform: translate(-100%, -100%); }
66.67%, 78% { inset: calc(100% - 8px) auto auto 50%; transform: translate(-50%, -100%); }
83.34%, 90% { inset: calc(100% - 8px) auto auto 8px; transform: translate(0, -100%); }
100% { inset: 30px auto auto 8px; transform: translate(0, 0); }
}
// 눌린 자리 표시도 같은 시계로 돈다 — 여섯 개가 1/6 씩 돌아가며 켜진다
.ps.is-demo .ps__pad {
animation-name: ps-pad;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ps.is-demo .ps__pad[data-pos="tl"] { animation-delay: 0s; }
.ps.is-demo .ps__pad[data-pos="tc"] { animation-delay: -1.66667s; }
.ps.is-demo .ps__pad[data-pos="tr"] { animation-delay: -1.33333s; }
.ps.is-demo .ps__pad[data-pos="br"] { animation-delay: -1s; }
.ps.is-demo .ps__pad[data-pos="bc"] { animation-delay: -0.66667s; }
.ps.is-demo .ps__pad[data-pos="bl"] { animation-delay: -0.33333s; }
@keyframes ps-pad {
0% { border-color: $subject-ink; background-color: $subject-ink; }
16.67% { border-color: rgba(23, 20, 26, .2); background-color: rgba(23, 20, 26, 0); }
100% { border-color: rgba(23, 20, 26, .2); background-color: rgba(23, 20, 26, 0); }
}
@media (max-width: 340px) {
.ps { gap: $sp-2; }
.ps__screen { height: 108px; }
.ps__toasts { width: 118px; }
.ps__msg { font-size: 10px; line-height: 14px; }
.ps__pads { grid-template-columns: repeat(3, 34px); gap: $sp-1; }
.ps__pad { height: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.ps.is-demo .ps__toasts,
.ps.is-demo .ps__pad { animation: none !important; }
.ps__toasts { transition: none; }
}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);
}
.ps {
box-sizing: border-box;
width: min(376px, 88vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ps__screen {
position: relative;
box-sizing: border-box;
width: 100%;
height: 136px;
overflow: hidden;
border: 2px solid rgba(23, 20, 26, 0.16);
border-radius: 16px;
background: #fff7e6;
}
.ps__bar {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
gap: 4px;
padding: 8px 12px;
background: rgba(23, 20, 26, 0.06);
}
.ps__bar i {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.22);
}
.ps__toasts {
position: absolute;
inset: calc(100% - 8px) auto auto calc(100% - 8px);
display: flex;
flex-direction: column;
gap: 4px;
width: 148px;
transform: translate(-100%, -100%);
transition: inset 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ps__screen[data-pos=tl] .ps__toasts {
inset: 30px auto auto 8px;
transform: translate(0, 0);
}
.ps__screen[data-pos=tc] .ps__toasts {
inset: 30px auto auto 50%;
transform: translate(-50%, 0);
}
.ps__screen[data-pos=tr] .ps__toasts {
inset: 30px auto auto calc(100% - 8px);
transform: translate(-100%, 0);
}
.ps__screen[data-pos=bl] .ps__toasts {
inset: calc(100% - 8px) auto auto 8px;
transform: translate(0, -100%);
}
.ps__screen[data-pos=bc] .ps__toasts {
inset: calc(100% - 8px) auto auto 50%;
transform: translate(-50%, -100%);
}
.ps__screen[data-pos=br] .ps__toasts {
inset: calc(100% - 8px) auto auto calc(100% - 8px);
transform: translate(-100%, -100%);
}
.ps__toast {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ps__dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: #2f6df6;
}
.ps__msg {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ps__pads {
display: grid;
grid-template-columns: repeat(3, 40px);
gap: 8px;
}
.ps__pad {
box-sizing: border-box;
display: flex;
margin: 0;
padding: 4px;
height: 24px;
border: 2px solid rgba(23, 20, 26, 0.2);
border-radius: 8px;
background: transparent;
cursor: pointer;
}
.ps__pad span {
display: block;
width: 14px;
height: 6px;
border-radius: 4px;
background: #2f6df6;
}
.ps__pad[data-pos=tl] span,
.ps__pad[data-pos=bl] span {
margin-right: auto;
}
.ps__pad[data-pos=tc] span,
.ps__pad[data-pos=bc] span {
margin: 0 auto;
}
.ps__pad[data-pos=tr] span,
.ps__pad[data-pos=br] span {
margin-left: auto;
}
.ps__pad[data-pos=tl],
.ps__pad[data-pos=tc],
.ps__pad[data-pos=tr] {
align-items: flex-start;
}
.ps__pad[data-pos=bl],
.ps__pad[data-pos=bc],
.ps__pad[data-pos=br] {
align-items: flex-end;
}
.ps__pad[aria-pressed=true] {
border-color: #17141a;
background: #17141a;
}
.ps__pad:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ps.is-demo .ps__toasts {
transition: none;
animation-name: ps-move;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes ps-move {
0%, 10% {
inset: 30px auto auto 8px;
transform: translate(0, 0);
}
16.67%, 27% {
inset: 30px auto auto 50%;
transform: translate(-50%, 0);
}
33.34%, 44% {
inset: 30px auto auto calc(100% - 8px);
transform: translate(-100%, 0);
}
50%, 61% {
inset: calc(100% - 8px) auto auto calc(100% - 8px);
transform: translate(-100%, -100%);
}
66.67%, 78% {
inset: calc(100% - 8px) auto auto 50%;
transform: translate(-50%, -100%);
}
83.34%, 90% {
inset: calc(100% - 8px) auto auto 8px;
transform: translate(0, -100%);
}
100% {
inset: 30px auto auto 8px;
transform: translate(0, 0);
}
}
.ps.is-demo .ps__pad {
animation-name: ps-pad;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ps.is-demo .ps__pad[data-pos=tl] {
animation-delay: 0s;
}
.ps.is-demo .ps__pad[data-pos=tc] {
animation-delay: -1.66667s;
}
.ps.is-demo .ps__pad[data-pos=tr] {
animation-delay: -1.33333s;
}
.ps.is-demo .ps__pad[data-pos=br] {
animation-delay: -1s;
}
.ps.is-demo .ps__pad[data-pos=bc] {
animation-delay: -0.66667s;
}
.ps.is-demo .ps__pad[data-pos=bl] {
animation-delay: -0.33333s;
}
@keyframes ps-pad {
0% {
border-color: #17141a;
background-color: #17141a;
}
16.67% {
border-color: rgba(23, 20, 26, 0.2);
background-color: rgba(23, 20, 26, 0);
}
100% {
border-color: rgba(23, 20, 26, 0.2);
background-color: rgba(23, 20, 26, 0);
}
}
@media (max-width: 340px) {
.ps {
gap: 8px;
}
.ps__screen {
height: 108px;
}
.ps__toasts {
width: 118px;
}
.ps__msg {
font-size: 10px;
line-height: 14px;
}
.ps__pads {
grid-template-columns: repeat(3, 34px);
gap: 4px;
}
.ps__pad {
height: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.ps.is-demo .ps__toasts,
.ps.is-demo .ps__pad {
animation: none !important;
}
.ps__toasts {
transition: none;
}
}// 실제 동작: 화면의 data-pos 한 글자쌍이 알림 묶음의 inset 과 쌓이는 방향을 한꺼번에 정한다
var root = document.querySelector('.ps');
var screen = document.getElementById('ps-screen');
var pads = [].slice.call(document.querySelectorAll('.ps__pad'));
pads.forEach(function (b) {
b.addEventListener('click', function () {
var pos = b.getAttribute('data-pos');
root.classList.remove('is-demo');
screen.setAttribute('data-pos', pos);
pads.forEach(function (o) { o.setAttribute('aria-pressed', String(o.getAttribute('data-pos') === pos)); });
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PositionSwitch.module.scss";
export type ToastPos = "tl" | "tc" | "tr" | "bl" | "bc" | "br";
export type PositionSwitchProps = {
messages?: string[];
regionLabel?: string;
groupLabel?: string;
padLabels?: Record<ToastPos, string>;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const PADS: ToastPos[] = ["tl", "tc", "tr", "bl", "bc", "br"];
export default function PositionSwitch({
messages = ["Link copied", "Draft saved"],
regionLabel = "Where toasts appear",
groupLabel = "Pick where toasts appear",
padLabels = { tl: "Top left", tc: "Top center", tr: "Top right", bl: "Bottom left", bc: "Bottom center", br: "Bottom right" },
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: PositionSwitchProps) {
const [demo, setDemo] = useState(true);
const [pos, setPos] = useState<ToastPos>("br");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.ps} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.screen} data-pos={pos}>
<span className={styles.bar} aria-hidden="true"><i /><i /><i /></span>
<div className={styles.toasts} role="region" aria-label={regionLabel}>
{messages.map((m) => (
<span key={m} className={styles.toast}>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.msg}>{m}</span>
</span>
))}
</div>
</div>
<div className={styles.pads} role="group" aria-label={groupLabel}>
{PADS.map((p) => (
<button
key={p}
className={styles.pad}
type="button"
data-pos={p}
aria-pressed={!demo && p === pos}
aria-label={padLabels[p]}
onClick={() => { setDemo(false); setPos(p); }}
>
<span />
</button>
))}
</div>
</div>
);
}08 여섯 자리 위치 전환 — 측정 누적면적 29.232% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 토스트 알림 UI 9종 — 쌓이고 밀리고 사라지는 스택
이 데모가 속한 글
토스트 알림 UI 9종 — 쌓이고 밀리고 사라지는 스택토스트 알림 UI는 화면 구석에 잠깐 떴다 사라지는 알림을 어떻게 쌓고 어떻게 치울지 정하는 설계입니다. 아홉 개 전부 라이브러리 없이 바닐라 JS와 SCSS로만 돌아가고, 첫 번째는 새 알림이 앞선 카드를 위로 밀 이 글의 데모 전부 보기


