03Kéo để làm mới
Kéo xuống từ đầu danh sách sẽ giãn ra như dây thun, thả tay thì spinner quay và làm mới dữ liệu.
Dùng choLàm mới feed mạng xã hội, cập nhật trạng thái đơn hàng
touch 이동 거리elastic ease-outspinner rotate keyframes

<main class="stage">
<div class="ptr is-demo">
<div class="ptr__indicator" aria-hidden="true"><span class="ptr__spinner"></span></div>
<div class="ptr__body">
<div class="ptr__head">
<span class="ptr__title">Feed của bạn</span>
<button class="ptr__refresh" type="button">Làm mới</button>
</div>
<ul class="ptr__list">
<li>3 gợi ý hôm nay</li>
<li>2 hoạt động của bạn bè</li>
</ul>
<p class="ptr__status" aria-live="polite">Vừa làm mới xong</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 당긴 만큼 늘어나는 시간
$easing: $ease-spring;
$color: $subject-blue; // 스피너 색
@include stage($stage-orange);
.ptr {
position: relative;
width: min(240px, 78vw);
overflow: hidden;
border-radius: $r-card;
}
.ptr__indicator {
position: absolute;
top: $sp-2;
left: 0;
right: 0;
display: flex;
justify-content: center;
}
.ptr__spinner {
width: 24px;
height: 24px;
border-radius: 50%;
border: 3px solid rgba($stage-paper, .4);
border-top-color: $color;
opacity: 0;
transition: opacity $duration $easing;
}
.ptr__body {
position: relative;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
transform: translateY(0);
transition: transform $duration $easing;
touch-action: pan-x;
cursor: grab;
}
.ptr__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.ptr__title { font-weight: 700; font-size: 14px; color: $stage-ink; }
.ptr__refresh {
border: 0;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $color;
color: $stage-paper;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
.ptr__list { list-style: none; margin: $sp-3 0 0; padding: 0; font-size: 12px; color: rgba(23, 20, 26, .7); display: flex; flex-direction: column; gap: $sp-1; }
.ptr__status { margin: $sp-2 0 0; font-size: 11px; color: rgba(23, 20, 26, .72); }
.ptr.is-refreshing .ptr__spinner { opacity: 1; animation: ptr-spin .7s linear infinite; }
.ptr.is-demo .ptr__body { animation: ptr-loop $dur-loop $easing infinite; }
.ptr.is-demo .ptr__spinner { animation: ptr-spin-loop $dur-loop $easing infinite; }
@keyframes ptr-spin { to { transform: rotate(360deg); } }
@keyframes ptr-loop {
0%, 10% { transform: translateY(0); }
40%, 60% { transform: translateY(56px); }
95%, 100% { transform: translateY(0); }
}
@keyframes ptr-spin-loop {
0%, 10% { opacity: 0; transform: rotate(0deg); }
40%, 60% { opacity: 1; transform: rotate(360deg); }
95%, 100% { opacity: 0; transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.ptr__body, .ptr__spinner { transition: none; }
.ptr.is-demo .ptr__body { animation: none; transform: translateY(56px); }
.ptr.is-demo .ptr__spinner { animation: none; opacity: 1; }
.ptr.is-refreshing .ptr__spinner { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ptr {
position: relative;
width: min(240px, 78vw);
overflow: hidden;
border-radius: 16px;
}
.ptr__indicator {
position: absolute;
top: 8px;
left: 0;
right: 0;
display: flex;
justify-content: center;
}
.ptr__spinner {
width: 24px;
height: 24px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.4);
border-top-color: #2f6df6;
opacity: 0;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ptr__body {
position: relative;
padding: 16px;
border-radius: 16px;
background: #ffffff;
transform: translateY(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
touch-action: pan-x;
cursor: grab;
}
.ptr__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ptr__title {
font-weight: 700;
font-size: 14px;
color: #17141a;
}
.ptr__refresh {
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #2f6df6;
color: #ffffff;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
.ptr__list {
list-style: none;
margin: 12px 0 0;
padding: 0;
font-size: 12px;
color: rgba(23, 20, 26, 0.7);
display: flex;
flex-direction: column;
gap: 4px;
}
.ptr__status {
margin: 8px 0 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.72);
}
.ptr.is-refreshing .ptr__spinner {
opacity: 1;
animation: ptr-spin 0.7s linear infinite;
}
.ptr.is-demo .ptr__body {
animation: ptr-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ptr.is-demo .ptr__spinner {
animation: ptr-spin-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ptr-spin {
to {
transform: rotate(360deg);
}
}
@keyframes ptr-loop {
0%, 10% {
transform: translateY(0);
}
40%, 60% {
transform: translateY(56px);
}
95%, 100% {
transform: translateY(0);
}
}
@keyframes ptr-spin-loop {
0%, 10% {
opacity: 0;
transform: rotate(0deg);
}
40%, 60% {
opacity: 1;
transform: rotate(360deg);
}
95%, 100% {
opacity: 0;
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ptr__body, .ptr__spinner {
transition: none;
}
.ptr.is-demo .ptr__body {
animation: none;
transform: translateY(56px);
}
.ptr.is-demo .ptr__spinner {
animation: none;
opacity: 1;
}
.ptr.is-refreshing .ptr__spinner {
animation: none;
}
}(function () {
var ptr = document.querySelector('.ptr');
var body = ptr.querySelector('.ptr__body');
var status = ptr.querySelector('.ptr__status');
var startY = 0, dy = 0, dragging = false, refreshing = false;
function refresh() {
if (refreshing) return;
refreshing = true;
ptr.classList.add('is-refreshing');
body.style.transform = 'translateY(56px)';
status.style.visibility = 'hidden';
setTimeout(function () {
refreshing = false;
ptr.classList.remove('is-refreshing');
body.style.transform = '';
status.style.visibility = '';
}, 900);
}
body.addEventListener('pointerdown', function (e) {
ptr.classList.remove('is-demo');
dragging = true; startY = e.clientY; body.setPointerCapture(e.pointerId);
});
body.addEventListener('pointermove', function (e) {
if (!dragging || refreshing) return;
dy = Math.max(0, Math.min(72, (e.clientY - startY) * 0.5));
body.style.transform = 'translateY(' + dy + 'px)';
});
function end() {
if (!dragging || refreshing) { dragging = false; return; }
dragging = false;
if (dy > 44) { refresh(); } else { body.style.transform = ''; }
dy = 0;
}
body.addEventListener('pointerup', end);
body.addEventListener('pointercancel', end);
ptr.querySelector('.ptr__refresh').addEventListener('click', function () {
ptr.classList.remove('is-demo'); refresh();
});
})();import { useRef, useState, type CSSProperties, type PointerEvent } from "react";
import styles from "./PullToRefreshSpinner.module.scss";
export type PullToRefreshSpinnerProps = {
title: string;
onRefresh?: () => void;
/** 당긴 만큼 늘어나는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 스피너 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PullToRefreshSpinner({
title,
onRefresh,
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: PullToRefreshSpinnerProps) {
const [dy, setDy] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const dragging = useRef(false);
const startY = useRef(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const refresh = () => {
if (refreshing) return;
setRefreshing(true);
setDy(56);
onRefresh?.();
setTimeout(() => { setRefreshing(false); setDy(0); }, 900);
};
const down = (e: PointerEvent<HTMLDivElement>) => {
dragging.current = true;
startY.current = e.clientY;
e.currentTarget.setPointerCapture(e.pointerId);
};
const move = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging.current || refreshing) return;
setDy(Math.max(0, Math.min(72, (e.clientY - (startY.current ?? 0)) * 0.5)));
};
const up = () => {
if (!dragging.current || refreshing) { dragging.current = false; return; }
dragging.current = false;
if (dy > 44) refresh(); else setDy(0);
};
return (
<div className={styles.ptr} style={vars}>
<div className={styles.indicator} aria-hidden="true">
<span className={styles.spinner} data-spin={refreshing || undefined} />
</div>
<div
className={styles.body}
style={{ transform: `translateY(${dy}px)` }}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
>
<div className={styles.head}>
<span className={styles.title}>{title}</span>
<button className={styles.refreshBtn} type="button" onClick={refresh}>새로고침</button>
</div>
<p className={styles.status} style={{ visibility: refreshing ? "hidden" : undefined }} aria-live="polite">
방금 새로고침함
</p>
</div>
</div>
);
}03 Kéo để làm mới — Diện tích đo được 14.518% · khung hình chuyển động 17/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 mẫu List UI: swipe to delete css và phân trang
Bài viết chứa demo này
9 mẫu List UI: swipe to delete css và phân trangSwipe to delete css là vuốt một dòng để lộ nút xóa phía sau — thao tác bạn hay thấy trong app, giờ có luôn trên web. Bài Xem tất cả demo trong bài này


