04드래그로 폴더에 넣기
파일 줄을 끌어 폴더 위에 대면 폴더가 테두리와 배경으로 반응하고, 놓는 순간 파일이 그 폴더 안으로 실제로 이동합니다.
쓰임정리 안 된 파일을 마우스로 끌어 분류하는, 자료실·업로드함·이미지 갤러리 정리 화면
pointer eventselementFromPointEscape

<main class="stage">
<div class="dg is-demo" id="dg">
<ul class="dg__list" id="dg-list" role="tree" aria-label="files" tabindex="0" aria-activedescendant="dg-f1">
<li class="dg__f" id="dg-f1" role="treeitem" aria-level="1" aria-expanded="false">
<div class="dg__row dg__row--f" data-hit>
<span class="dg__ico dg__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="dg__name">uploads</span>
<span class="dg__n" aria-label="0개">0</span>
</div>
<div class="dg__kids"><div class="dg__kwrap"><ul role="group" aria-label="uploads"></ul></div></div>
</li>
<li class="dg__f" id="dg-f2" role="treeitem" aria-level="1" aria-expanded="false">
<div class="dg__row dg__row--f" data-hit>
<span class="dg__ico dg__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="dg__name">archive</span>
<span class="dg__n" aria-label="0개">0</span>
</div>
<div class="dg__kids"><div class="dg__kwrap"><ul role="group" aria-label="archive"></ul></div></div>
</li>
<li class="dg__i" id="dg-i1" role="treeitem" aria-level="1" aria-label="photo-01.jpg 끌어 폴더로 이동">
<div class="dg__row" data-hit tabindex="-1">
<span class="dg__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">photo-01.jpg</span>
</div>
</li>
<li class="dg__i" id="dg-i2" role="treeitem" aria-level="1" aria-label="report.pdf 끌어 폴더로 이동">
<div class="dg__row" data-hit tabindex="-1">
<span class="dg__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M52 144H36a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">report.pdf</span>
</div>
</li>
<li class="dg__i" id="dg-i3" role="treeitem" aria-level="1" aria-label="data.zip 끌어 폴더로 이동">
<div class="dg__row" data-hit tabindex="-1">
<span class="dg__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span>
<span class="dg__name">data.zip</span>
</div>
</li>
</ul>
<div class="dg__after" aria-hidden="true">
<ul>
<li><div class="dg__row dg__row--f dg__row--flash">
<span class="dg__ico dg__ico--folder"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="dg__name">uploads</span>
<span class="dg__n dg__n--on">1</span>
</div>
<ul class="dg__inkids"><li><div class="dg__row dg__row--in">
<span class="dg__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">photo-01.jpg</span>
</div></li></ul></li>
<li><div class="dg__row dg__row--f">
<span class="dg__ico dg__ico--folder"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="dg__name">archive</span>
<span class="dg__n">0</span>
</div></li>
<li><div class="dg__row">
<span class="dg__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M52 144H36a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">report.pdf</span>
</div></li>
<li><div class="dg__row">
<span class="dg__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span>
<span class="dg__name">data.zip</span>
</div></li>
</ul>
</div>
<div class="dg__ghost" aria-hidden="true">
<span class="dg__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span>
photo-01.jpg
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.dg {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.dg__list, .dg__list ul, .dg__after ul {
list-style: none;
margin: 0; padding: 0;
}
.dg__list:focus-visible { outline: none; }
.dg__list li[aria-selected="true"] > .dg__row { outline: 2px solid $color; outline-offset: -2px; }
.dg__list:focus-visible .dg__row { outline: none; }
.dg__row {
box-sizing: border-box;
height: 26px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
margin-bottom: $sp-1;
border: 1px solid rgba(23, 20, 26, .1);
border-radius: $r-xs;
background: #fff;
font-size: 12px; font-weight: 600;
cursor: grab;
user-select: none;
transition: border-color $duration $easing, background $duration $easing, box-shadow $duration $easing;
}
.dg__row--f { cursor: pointer; }
.dg__row:hover { border-color: rgba(23, 20, 26, .24); }
.dg__ico { display: inline-flex; flex: 0 0 auto; color: $stage-ink; }
.dg__ico--folder { color: $color; }
.dg__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dg__n {
margin-left: auto;
flex: 0 0 auto;
min-width: 16px; height: 16px;
display: inline-flex; align-items: center; justify-content: center;
padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
font-size: 10px; line-height: 16px; font-weight: 800;
color: rgba(23, 20, 26, .72);
}
.dg__n--on { background: $color; color: #fff; }
// 폴더가 드롭 대상으로 반응하는 순간
.dg__row.is-hot {
border-color: $color;
background: rgba(47, 109, 246, .08);
box-shadow: 0 0 0 2px rgba(47, 109, 246, .25);
}
.dg__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.dg__kwrap { overflow: hidden; min-height: 0; }
.dg__f:has(.dg__kids li) > .dg__kids { grid-template-rows: 1fr; }
.dg__kids .dg__row { margin-left: $sp-4; }
.dg__row.is-in { animation-name: dg-in; animation-duration: 320ms; animation-timing-function: $ease-pop; animation-iteration-count: 1; }
@keyframes dg-in { 0% { transform: scale(.94); background: rgba(47, 109, 246, .16); } 100% { transform: scale(1); background: #fff; } }
// 루프용 결과 상태 — 실제 목록 위에 겹쳐 놓는다
.dg:not(.is-demo) .dg__after, .dg:not(.is-demo) .dg__ghost { display: none; }
.dg__after {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.dg__inkids { padding-left: $sp-4; }
.dg__row--in { margin-bottom: $sp-1; }
.dg__row--flash { border-color: $color; background: rgba(47, 109, 246, .08); }
// 끌리는 파일 조각
.dg__ghost {
position: absolute;
left: 24px; top: 65px;
display: inline-flex; align-items: center; gap: $sp-1;
height: 24px;
padding: 0 $sp-2;
border-radius: $r-xs;
background: #fff;
border: 1px solid rgba(23, 20, 26, .14);
box-shadow: $shadow-raised;
font-size: 11px; font-weight: 700;
color: $stage-ink;
opacity: 0;
pointer-events: none;
z-index: 2;
}
.dg__ghost.is-live { position: fixed; opacity: 1; }
// ── 자동 루프: 조각이 떠올라 폴더로 들어가고 목록이 컷으로 바뀐다 ──
.dg.is-demo .dg__list {
animation-name: dg-list;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-list {
0% { opacity: 0; }
4% { opacity: 1; }
39.9% { opacity: 1; }
40% { opacity: 0; }
100% { opacity: 0; }
}
.dg.is-demo .dg__after {
animation-name: dg-after;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-after {
0% { opacity: 0; }
39.9% { opacity: 0; }
40% { opacity: 1; }
82% { opacity: 1; }
94%, 100% { opacity: 0; }
}
.dg.is-demo .dg__ghost {
animation-name: dg-ghost;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-ghost {
0%, 4% { opacity: 0; transform: translate(0, 0); }
5% { opacity: 1; }
6% { transform: translate(0, 0) scale(.96); }
34% { transform: translate(10px, -52px) scale(.96); }
39.9% { opacity: 1; }
40%, 100% { opacity: 0; transform: translate(10px, -52px) scale(.96); }
}
.dg.is-demo .dg__list .dg__f:first-child .dg__row {
animation-name: dg-hot;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dg-hot {
0%, 29.9% { border-color: rgba(23, 20, 26, .1); box-shadow: none; }
30%, 39.9% { border-color: $color; box-shadow: 0 0 0 2px rgba(47, 109, 246, .25); }
40%, 100% { border-color: rgba(23, 20, 26, .1); box-shadow: none; }
}
@media (max-width: 340px) {
.dg__row { height: 22px; font-size: 11px; }
@keyframes dg-ghost {
0%, 4% { opacity: 0; transform: translate(0, 0); }
5% { opacity: 1; }
6% { transform: translate(0, 0) scale(.96); }
34% { transform: translate(10px, -46px) scale(.96); }
39.9% { opacity: 1; }
40%, 100% { opacity: 0; transform: translate(10px, -46px) scale(.96); }
}
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__list, .dg.is-demo .dg__after, .dg.is-demo .dg__ghost, .dg.is-demo .dg__list .dg__f:first-child .dg__row { animation: none !important; }
.dg__row, .dg__kids { transition: none; }
.dg:not(.is-demo) .dg__after { display: 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);
}
.dg {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.dg__list, .dg__list ul, .dg__after ul {
list-style: none;
margin: 0;
padding: 0;
}
.dg__list:focus-visible {
outline: none;
}
.dg__list li[aria-selected=true] > .dg__row {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.dg__list:focus-visible .dg__row {
outline: none;
}
.dg__row {
box-sizing: border-box;
height: 26px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
margin-bottom: 4px;
border: 1px solid rgba(23, 20, 26, 0.1);
border-radius: 4px;
background: #fff;
font-size: 12px;
font-weight: 600;
cursor: grab;
user-select: none;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dg__row--f {
cursor: pointer;
}
.dg__row:hover {
border-color: rgba(23, 20, 26, 0.24);
}
.dg__ico {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.dg__ico--folder {
color: #2f6df6;
}
.dg__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dg__n {
margin-left: auto;
flex: 0 0 auto;
min-width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
font-size: 10px;
line-height: 16px;
font-weight: 800;
color: rgba(23, 20, 26, 0.72);
}
.dg__n--on {
background: #2f6df6;
color: #fff;
}
.dg__row.is-hot {
border-color: #2f6df6;
background: rgba(47, 109, 246, 0.08);
box-shadow: 0 0 0 2px rgba(47, 109, 246, 0.25);
}
.dg__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dg__kwrap {
overflow: hidden;
min-height: 0;
}
.dg__f:has(.dg__kids li) > .dg__kids {
grid-template-rows: 1fr;
}
.dg__kids .dg__row {
margin-left: 16px;
}
.dg__row.is-in {
animation-name: dg-in;
animation-duration: 320ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes dg-in {
0% {
transform: scale(0.94);
background: rgba(47, 109, 246, 0.16);
}
100% {
transform: scale(1);
background: #fff;
}
}
.dg:not(.is-demo) .dg__after, .dg:not(.is-demo) .dg__ghost {
display: none;
}
.dg__after {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.dg__inkids {
padding-left: 16px;
}
.dg__row--in {
margin-bottom: 4px;
}
.dg__row--flash {
border-color: #2f6df6;
background: rgba(47, 109, 246, 0.08);
}
.dg__ghost {
position: absolute;
left: 24px;
top: 65px;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border-radius: 4px;
background: #fff;
border: 1px solid rgba(23, 20, 26, 0.14);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-size: 11px;
font-weight: 700;
color: #17141a;
opacity: 0;
pointer-events: none;
z-index: 2;
}
.dg__ghost.is-live {
position: fixed;
opacity: 1;
}
.dg.is-demo .dg__list {
animation-name: dg-list;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-list {
0% {
opacity: 0;
}
4% {
opacity: 1;
}
39.9% {
opacity: 1;
}
40% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.dg.is-demo .dg__after {
animation-name: dg-after;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-after {
0% {
opacity: 0;
}
39.9% {
opacity: 0;
}
40% {
opacity: 1;
}
82% {
opacity: 1;
}
94%, 100% {
opacity: 0;
}
}
.dg.is-demo .dg__ghost {
animation-name: dg-ghost;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-ghost {
0%, 4% {
opacity: 0;
transform: translate(0, 0);
}
5% {
opacity: 1;
}
6% {
transform: translate(0, 0) scale(0.96);
}
34% {
transform: translate(10px, -52px) scale(0.96);
}
39.9% {
opacity: 1;
}
40%, 100% {
opacity: 0;
transform: translate(10px, -52px) scale(0.96);
}
}
.dg.is-demo .dg__list .dg__f:first-child .dg__row {
animation-name: dg-hot;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dg-hot {
0%, 29.9% {
border-color: rgba(23, 20, 26, 0.1);
box-shadow: none;
}
30%, 39.9% {
border-color: #2f6df6;
box-shadow: 0 0 0 2px rgba(47, 109, 246, 0.25);
}
40%, 100% {
border-color: rgba(23, 20, 26, 0.1);
box-shadow: none;
}
}
@media (max-width: 340px) {
.dg__row {
height: 22px;
font-size: 11px;
}
@keyframes dg-ghost {
0%, 4% {
opacity: 0;
transform: translate(0, 0);
}
5% {
opacity: 1;
}
6% {
transform: translate(0, 0) scale(0.96);
}
34% {
transform: translate(10px, -46px) scale(0.96);
}
39.9% {
opacity: 1;
}
40%, 100% {
opacity: 0;
transform: translate(10px, -46px) scale(0.96);
}
}
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__list, .dg.is-demo .dg__after, .dg.is-demo .dg__ghost, .dg.is-demo .dg__list .dg__f:first-child .dg__row {
animation: none !important;
}
.dg__row, .dg__kids {
transition: none;
}
.dg:not(.is-demo) .dg__after {
display: none;
}
}// 실제 동작: 포인터로 끌어 폴더에 놓는다(마우스·터치). 키보드는 Enter 로 이동 모드.
var root = document.querySelector('.dg');
var list = document.getElementById('dg-list');
var files = [].slice.call(list.querySelectorAll('.dg__i .dg__row'));
var folders = [].slice.call(list.querySelectorAll('.dg__f'));
function clearHot() { folders.forEach(function (f) { f.querySelector('.dg__row').classList.remove('is-hot'); }); }
function bump(f) {
var n = f.querySelector('.dg__n'), k = f.querySelectorAll('.dg__kids li').length;
n.textContent = k; n.classList.toggle('dg__n--on', k > 0);
f.setAttribute('aria-expanded', String(k > 0));
}
function drop(file, folder) {
folder.querySelector('.dg__kids ul').appendChild(file.closest('li'));
file.classList.add('is-in');
setTimeout(function () { file.classList.remove('is-in'); }, 320);
bump(folder);
}
files.forEach(function (row) {
row.addEventListener('pointerdown', function (e) {
if (e.button && e.button !== 0) return;
root.classList.remove('is-demo');
var gh = document.querySelector('.dg__ghost').cloneNode(true);
gh.classList.add('is-live'); gh.removeAttribute('aria-hidden');
gh.style.left = e.clientX + 'px'; gh.style.top = e.clientY + 'px';
document.body.appendChild(gh);
row.setPointerCapture(e.pointerId);
function mv(ev) {
gh.style.transform = 'translate(' + (ev.clientX - e.clientX) + 'px,' + (ev.clientY - e.clientY) + 'px) scale(.96)';
gh.style.left = e.clientX + 'px'; gh.style.top = e.clientY + 'px';
var hit = document.elementFromPoint(ev.clientX, ev.clientY);
clearHot();
var f = hit && hit.closest && hit.closest('.dg__f');
if (f) f.querySelector('.dg__row').classList.add('is-hot');
}
function up(ev) {
row.removeEventListener('pointermove', mv);
row.removeEventListener('pointerup', up);
var hit = document.elementFromPoint(ev.clientX, ev.clientY);
var f = hit && hit.closest && hit.closest('.dg__f');
clearHot();
gh.remove();
if (f) drop(row, f);
}
row.addEventListener('pointermove', mv);
row.addEventListener('pointerup', up);
e.preventDefault();
});
});
var moving = null, moveRow = null;
var rows = [].slice.call(list.querySelectorAll('li[role="treeitem"]'));
rows.forEach(function (li, i) {
li.querySelector('.dg__row').addEventListener('click', function () { list.setAttribute('aria-activedescendant', li.id); });
});
list.addEventListener('keydown', function (e) {
if (!moving && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
var cur = rows.indexOf(document.getElementById(list.getAttribute('aria-activedescendant')));
var nxt = Math.max(0, Math.min(cur + (e.key === 'ArrowDown' ? 1 : -1), rows.length - 1));
list.setAttribute('aria-activedescendant', rows[nxt].id);
e.preventDefault();
return;
}
if (moving) {
var k = folders.indexOf(moving);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
clearHot();
moving = folders[(k + (e.key === 'ArrowDown' ? 1 : folders.length - 1)) % folders.length];
moving.querySelector('.dg__row').classList.add('is-hot');
list.setAttribute('aria-activedescendant', moving.id);
e.preventDefault();
} else if (e.key === 'Enter') {
var f = moving, row = moveRow;
moving = null; moveRow = null; clearHot();
if (row) drop(row, f);
e.preventDefault();
} else if (e.key === 'Escape') {
moving = null; moveRow = null; clearHot();
e.preventDefault();
}
return;
}
var at = list.getAttribute('aria-activedescendant');
var li = at && document.getElementById(at);
if (e.key === 'Enter' && li && li.classList.contains('dg__i')) {
root.classList.remove('is-demo');
moving = folders[0]; moveRow = li.querySelector('.dg__row');
clearHot();
moving.querySelector('.dg__row').classList.add('is-hot');
e.preventDefault();
}
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DragIntoFolderTree.module.scss";
export type FileKind = "image" | "pdf" | "zip";
export type FolderItem = { id: string; name: string };
export type FileItem = { id: string; name: string; kind: FileKind };
export type DragIntoFolderTreeProps = {
label?: string;
folders?: FolderItem[];
files?: FileItem[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type PointerEvt = {
button: number;
clientX: number;
clientY: number;
pointerId: number;
currentTarget: { setPointerCapture: (id: number) => void };
preventDefault: () => void;
};
type PtrMove = { clientX: number; clientY: number };
type KeyDown = { key: string };
function FolderIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" />
<path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" />
</g>
</svg>
);
}
function FileIcon({ kind }: { kind: FileKind }) {
if (kind === "image") {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2" />
<path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z" />
</g>
</svg>
);
}
if (kind === "pdf") {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M52 144H36a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z" />
</g>
</svg>
);
}
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z" />
</g>
</svg>
);
}
const DEFAULT_FOLDERS: FolderItem[] = [
{ id: "uploads", name: "uploads" },
{ id: "archive", name: "archive" },
];
const DEFAULT_FILES: FileItem[] = [
{ id: "photo", name: "photo-01.jpg", kind: "image" },
{ id: "report", name: "report.pdf", kind: "pdf" },
{ id: "data", name: "data.zip", kind: "zip" },
];
export default function DragIntoFolderTree({
label = "files",
folders = DEFAULT_FOLDERS,
files = DEFAULT_FILES,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: DragIntoFolderTreeProps) {
const [where, setWhere] = useState<{ [k: string]: string | null }>({});
const [drag, setDrag] = useState<{ id: string; x: number; y: number } | null>(null);
const [hot, setHot] = useState<string | null>(null);
const [justIn, setJustIn] = useState<string | null>(null);
const [moving, setMoving] = useState<{ fileId: string; folder: number } | null>(null);
const [at, setAt] = useState(0);
const listRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const kidsOf = (fid: string) => files.filter((f) => where[f.id] === fid);
const loose = files.filter((f) => !where[f.id]);
const rows: { key: string; file: FileItem | null }[] = [];
folders.forEach((fo) => {
rows.push({ key: fo.id, file: null });
kidsOf(fo.id).forEach((f) => rows.push({ key: f.id, file: f }));
});
loose.forEach((f) => rows.push({ key: f.id, file: f }));
const cur = Math.max(0, Math.min(at, rows.length - 1));
const active = rows[cur];
function drop(fileId: string, folderId: string) {
setWhere((w) => {
const next: { [k: string]: string | null } = {};
for (const k in w) next[k] = w[k];
next[fileId] = folderId;
return next;
});
setJustIn(fileId);
window.setTimeout(() => setJustIn(null), 320);
}
// 포인터로 끌어 폴더에 놓는다 (마우스·터치). Escape 로 취소.
useEffect(() => {
if (!drag) return;
const id = drag.id;
function folderAt(x: number, y: number): string | null {
const el = document.elementFromPoint(x, y);
const f = el ? el.closest("[data-folder]") : null;
return f ? f.getAttribute("data-folder") : null;
}
function onMove(ev: PtrMove) {
const x = ev.clientX;
const y = ev.clientY;
setDrag((d) => (d ? { id: d.id, x: x, y: y } : d));
setHot(folderAt(x, y));
}
function onUp(ev: PtrMove) {
const fid = folderAt(ev.clientX, ev.clientY);
setDrag(null);
setHot(null);
if (fid) drop(id, fid);
}
function onKey(ev: KeyDown) {
if (ev.key === "Escape") {
setDrag(null);
setHot(null);
}
}
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
document.addEventListener("pointercancel", onUp);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
document.removeEventListener("pointercancel", onUp);
document.removeEventListener("keydown", onKey);
};
}, [drag ? drag.id : null]);
function grab(e: PointerEvt, fileId: string) {
if (e.button && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
setDrag({ id: fileId, x: e.clientX, y: e.clientY });
setMoving(null);
listRef.current?.focus({ preventScroll: true });
e.preventDefault();
}
function onKeyDown(e: KeyEvt) {
if (moving) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
const step = e.key === "ArrowDown" ? 1 : folders.length - 1;
const next = (moving.folder + step) % folders.length;
setMoving({ fileId: moving.fileId, folder: next });
setHot(folders[next].id);
e.preventDefault();
} else if (e.key === "Enter" || e.key === " ") {
drop(moving.fileId, folders[moving.folder].id);
setMoving(null);
setHot(null);
e.preventDefault();
} else if (e.key === "Escape") {
setMoving(null);
setHot(null);
e.preventDefault();
}
return;
}
if (e.key === "ArrowDown") { setAt(Math.min(cur + 1, rows.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setAt(Math.max(cur - 1, 0)); e.preventDefault(); }
else if (e.key === "Home") { setAt(0); e.preventDefault(); }
else if (e.key === "End") { setAt(rows.length - 1); e.preventDefault(); }
else if ((e.key === "Enter" || e.key === " ") && active && active.file) {
setMoving({ fileId: active.file.id, folder: 0 });
setHot(folders[0].id);
e.preventDefault();
}
}
function fileRow(f: FileItem, inside: boolean) {
const rowCls = [styles.row, inside ? styles.rowIn : "", justIn === f.id ? styles.isIn : ""]
.filter(Boolean)
.join(" ");
return (
<li
key={f.id}
id={`dg-${f.id}`}
className={styles.item}
role="treeitem"
aria-level={inside ? 2 : 1}
aria-selected={active ? active.key === f.id : false}
aria-grabbed={drag ? drag.id === f.id : moving ? moving.fileId === f.id : false}
>
<div
className={rowCls}
onPointerDown={(e: PointerEvt) => grab(e, f.id)}
onClick={() => setAt(rows.findIndex((r) => r.key === f.id))}
>
<span className={styles.ico} aria-hidden="true"><FileIcon kind={f.kind} /></span>
<span className={styles.name}>{f.name}</span>
</div>
</li>
);
}
const ghostFile = drag ? files.filter((f) => f.id === drag.id)[0] : null;
return (
<div className={styles.root} style={vars}>
<ul
ref={listRef}
className={styles.list}
role="tree"
aria-label={label}
tabIndex={0}
aria-activedescendant={active ? `dg-${active.key}` : undefined}
onKeyDown={onKeyDown}
>
{folders.map((fo) => {
const kids = kidsOf(fo.id);
return (
<li
key={fo.id}
id={`dg-${fo.id}`}
className={styles.item}
role="treeitem"
aria-level={1}
aria-expanded={kids.length > 0}
aria-selected={active ? active.key === fo.id : false}
data-folder={fo.id}
>
<div
className={`${styles.row} ${styles.rowFolder} ${hot === fo.id ? styles.isHot : ""}`}
onClick={() => setAt(rows.findIndex((r) => r.key === fo.id))}
>
<span className={`${styles.ico} ${styles.icoFolder}`} aria-hidden="true"><FolderIcon /></span>
<span className={styles.name}>{fo.name}</span>
<span className={`${styles.count} ${kids.length > 0 ? styles.countOn : ""}`}>{kids.length}</span>
</div>
<div className={`${styles.kids} ${kids.length > 0 ? styles.kidsOpen : ""}`}>
<div className={styles.kwrap}>
<ul className={styles.group} role="group" aria-label={fo.name}>
{kids.map((f) => fileRow(f, true))}
</ul>
</div>
</div>
</li>
);
})}
{loose.map((f) => fileRow(f, false))}
</ul>
{drag && ghostFile ? (
<div
className={styles.ghost}
style={{ left: `${drag.x}px`, top: `${drag.y}px` }}
aria-hidden="true"
>
<span className={styles.ico}><FileIcon kind={ghostFile.kind} /></span>
{ghostFile.name}
</div>
) : null}
</div>
);
}04 드래그로 폴더에 넣기 — 측정 누적면적 13.852% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 파일 트리 만들기 9종 — 폴더가 진짜로 열리는 사이드바
이 데모가 속한 글
파일 트리 만들기 9종 — 폴더가 진짜로 열리는 사이드바파일 트리 만들기는 폴더와 문서가 겹겹이 들어간 목록을, 누르면 실제로 펼쳐지고 접히는 사이드바로 직접 짜는 일입니다. 여기 아홉 개는 모양만 비슷한 게 아니라 끌어 넣기, 이름 고치기, 검색으로 좁히기, 용량 보기 이 글의 데모 전부 보기


