08Six-corner position switch
Pick one of six slots, three along the top and three along the bottom, and the stack moves to that corner with its entry direction and stacking order flipped to match.
Best forProducts that already park a chat widget in the bottom-right corner
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="Where toasts appear">
<span class="ps__toast">
<span class="ps__dot" aria-hidden="true"></span>
<span class="ps__msg">Link copied</span>
</span>
<span class="ps__toast">
<span class="ps__dot" aria-hidden="true"></span>
<span class="ps__msg">Draft saved</span>
</span>
</div>
</div>
<div class="ps__pads" role="group" aria-label="Pick where toasts appear">
<button class="ps__pad" type="button" data-pos="tl" aria-pressed="false" aria-label="Top left"><span></span></button>
<button class="ps__pad" type="button" data-pos="tc" aria-pressed="false" aria-label="Top center"><span></span></button>
<button class="ps__pad" type="button" data-pos="tr" aria-pressed="false" aria-label="Top right"><span></span></button>
<button class="ps__pad" type="button" data-pos="bl" aria-pressed="false" aria-label="Bottom left"><span></span></button>
<button class="ps__pad" type="button" data-pos="bc" aria-pressed="false" aria-label="Bottom center"><span></span></button>
<button class="ps__pad" type="button" data-pos="br" aria-pressed="true" aria-label="Bottom right"><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 Six-corner position switch — Measured area 29.232% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · Toast Notification UI: 9 Stacking Patterns
The post this demo lives in
Toast Notification UI: 9 Stacking PatternsToast notification UI is what decides how those corner messages pile up and how they leave again. All nine run without a See all demos in this post


