08A Handle Whose Guide Lines Flash When It Meets a Grid or Neighbor
As the dragged edge nears a grid line or a neighbor edge it is pulled harder the closer it gets, and a guide line lights up on contact then fades.
Best forLayout editors
GRID_STEPSNAP_DISTGUIDE_FADEsnapEdge

<main class="stage">
<div class="rh is-demo" id="rh" data-demo="1">
<i class="rh__grid"></i>
<i class="rh__nb" data-r="nb"></i>
<div class="rh__box" data-r="box">
<button class="rh__grip" data-r="grip" type="button" aria-label="Resize handle"></button>
</div>
<i class="rh__gv" data-r="gv"></i>
<i class="rh__gh" data-r="gh"></i>
<p class="rh__meta"><span class="rh__tag">GAP</span> <b data-r="gap">0</b> <span class="rh__tag">STEP</span> <b data-r="step">0</b></p>
<i class="rh__cursor" data-r="cursor"></i>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 격자 간격(--step)은 JS 가 한 번 정하고 배경 격자선과 흡착 계산이 같은 숫자를 쓴다.
@include stage($stage-paper);
.rh {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.rh b { font-weight: 800; font-variant-numeric: tabular-nums; }
.rh__tag { color: rgba($subject-ink, .72); letter-spacing: .5px; }
.rh__cursor {
position: absolute;
left: -10px;
top: -10px;
width: 20px;
height: 20px;
border-radius: 50%;
background: $subject-cream;
box-shadow: 0 0 0 3px rgba($subject-ink, .55), $shadow-press;
opacity: 0;
pointer-events: none;
z-index: 6;
}
.rh[data-demo] .rh__cursor { opacity: 1; }
.rh button:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.rh__cursor { display: none; }
}
// 안내선은 흡착 순간 불투명도 1, 이후 지수로 줄어든다.
.rh { overflow: hidden; border-radius: $r-card; background: $subject-cream; }
.rh__grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba($subject-ink, .12) 1px, transparent 1px),
linear-gradient(to bottom, rgba($subject-ink, .12) 1px, transparent 1px);
background-size: var(--step) var(--step);
}
.rh__nb {
position: absolute;
left: 0;
top: 0;
border-radius: $r-sm;
background: $subject-sky;
box-shadow: 0 6px 12px rgba($subject-ink, .18);
}
.rh__box {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border: 2px solid $subject-ink;
border-radius: $r-sm;
background: rgba($subject-lilac, .86);
box-shadow: 0 8px 16px rgba($subject-ink, .22);
}
.rh__grip {
position: absolute;
right: -2px;
bottom: -2px;
width: 24px;
height: 24px;
padding: 0;
border: 2px solid $subject-ink;
border-radius: $r-xs;
background: $subject-cream;
cursor: nwse-resize;
touch-action: none;
}
.rh__gv,
.rh__gh {
position: absolute;
left: 0;
top: 0;
opacity: 0;
pointer-events: none;
}
.rh__gv { width: 2px; height: 100%; background: $subject-orange-deep; }
.rh__gh { width: 100%; height: 2px; background: $subject-orange-deep; }
.rh__meta { position: absolute; left: $sp-2; bottom: $sp-1; margin: 0; z-index: 3; }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);
}
.rh {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.rh b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.rh__tag {
color: rgba(23, 20, 26, 0.72);
letter-spacing: 0.5px;
}
.rh__cursor {
position: absolute;
left: -10px;
top: -10px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 0 0 3px rgba(23, 20, 26, 0.55), 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.rh[data-demo] .rh__cursor {
opacity: 1;
}
.rh button:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.rh__cursor {
display: none;
}
}
.rh {
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
}
.rh__grid {
position: absolute;
inset: 0;
background-image: linear-gradient(to right, rgba(23, 20, 26, 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgba(23, 20, 26, 0.12) 1px, transparent 1px);
background-size: var(--step) var(--step);
}
.rh__nb {
position: absolute;
left: 0;
top: 0;
border-radius: 8px;
background: #6ec8ff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.18);
}
.rh__box {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border: 2px solid #17141a;
border-radius: 8px;
background: rgba(185, 160, 255, 0.86);
box-shadow: 0 8px 16px rgba(23, 20, 26, 0.22);
}
.rh__grip {
position: absolute;
right: -2px;
bottom: -2px;
width: 24px;
height: 24px;
padding: 0;
border: 2px solid #17141a;
border-radius: 4px;
background: #fff7e6;
cursor: nwse-resize;
touch-action: none;
}
.rh__gv,
.rh__gh {
position: absolute;
left: 0;
top: 0;
opacity: 0;
pointer-events: none;
}
.rh__gv {
width: 2px;
height: 100%;
background: #c93000;
}
.rh__gh {
width: 100%;
height: 2px;
background: #c93000;
}
.rh__meta {
position: absolute;
left: 8px;
bottom: 4px;
margin: 0;
z-index: 3;
}(function () {
const root = document.getElementById("rh");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
const offs = [];
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function stopDemo() {
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
}
function pos(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function spring(x, v, to, k, c, dt) {
const n = Math.max(1, Math.ceil(dt / 0.008));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
v += (-k * (x - to) - c * v) * h;
x += v * h;
}
return [x, v];
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 가장자리가 격자선·이웃 가장자리에서 SNAP_DIST 안이면 거리에 비례해 끌려간다.
// 끌림은 v = c + (raw - c) * (d / SNAP_DIST) 라서 선 위에서는 평평하고 멀어질수록 손을 따라간다.
// 선에 거의 닿으면 안내선이 번쩍 켜지고 GUIDE_FADE 시간 상수로 사라진다.
const GRID_STEP = 24;
const SNAP_DIST = 12;
const GUIDE_FADE = 0.35;
const MIN_SIDE = 48;
const box = q("[data-r=box]");
const nb = q("[data-r=nb]");
const grip = q("[data-r=grip]");
const gv = q("[data-r=gv]");
const gh = q("[data-r=gh]");
const gapEl = q("[data-r=gap]");
const stepEl = q("[data-r=step]");
const cursor = q("[data-r=cursor]");
let bw = 0;
let bh = 0;
let rawR = 0;
let rawB = 0;
let px = 0;
let py = 0;
let gx = 0;
let gy = 0;
let aV = 0;
let aH = 0;
let posV = 0;
let posH = 0;
let dragging = false;
let ready = false;
root.style.setProperty("--step", GRID_STEP + "px");
function nbLeft() { return Math.floor(root.clientWidth * 0.62) + 7; }
function nbWidth() { return Math.max(56, root.clientWidth * 0.22); }
function nbTop() { return GRID_STEP + 5; }
function nbHeight() { return Math.floor(root.clientHeight * 0.4); }
function snapEdge(r, cands) {
let best = cands[0];
for (let i = 1; i < cands.length; i += 1) {
if (Math.abs(cands[i] - r) < Math.abs(best - r))
best = cands[i];
}
const d = Math.abs(r - best);
if (d >= SNAP_DIST)
return [r, 0, best];
return [best + (r - best) * (d / SNAP_DIST), d < SNAP_DIST * 0.4 ? 1 : 0, best];
}
function resolve(dt) {
const gridX = Math.round(rawR / GRID_STEP) * GRID_STEP;
const gridY = Math.round(rawB / GRID_STEP) * GRID_STEP;
const sx = snapEdge(rawR, [gridX, nbLeft(), nbLeft() + nbWidth()]);
const sy = snapEdge(rawB, [gridY, nbTop() + nbHeight()]);
bw = Math.max(MIN_SIDE, sx[0] - GRID_STEP);
bh = Math.max(MIN_SIDE, sy[0] - GRID_STEP);
if (sx[1] === 1) {
aV = 1;
posV = sx[2];
}
else
aV *= Math.exp(-dt / GUIDE_FADE);
if (sy[1] === 1) {
aH = 1;
posH = sy[2];
}
else
aH *= Math.exp(-dt / GUIDE_FADE);
}
function init() {
if (ready)
return;
ready = true;
rawR = root.clientWidth * 0.3;
rawB = root.clientHeight * 0.42;
resolve(0);
}
function userTakes() {
stopDemo();
dragging = false;
init();
}
function script(now) {
const W = root.clientWidth;
const H = root.clientHeight;
const ph = now % 2000;
const x0 = W * 0.3;
const y0 = H * 0.42;
const x1 = W * 0.88;
const y1 = H * 0.9;
let t = 0;
if (ph >= 200 && ph < 1100)
t = (ph - 200) / 900;
else if (ph >= 1100)
t = 1 - (ph - 1100) / 900;
rawR = x0 + (x1 - x0) * t;
rawB = y0 + (y1 - y0) * t;
px = rawR - 8;
py = rawB - 8;
dragging = ph >= 200;
}
function snap() { init(); }
function step(dt, now) {
init();
if (demo)
script(now);
else if (dragging) {
rawR = clamp(px + gx, GRID_STEP + MIN_SIDE, root.clientWidth);
rawB = clamp(py + gy, GRID_STEP + MIN_SIDE, root.clientHeight);
}
resolve(dt);
}
function render() {
box.style.transform = "translate(" + GRID_STEP + "px," + GRID_STEP + "px)";
box.style.width = bw.toFixed(1) + "px";
box.style.height = bh.toFixed(1) + "px";
nb.style.width = nbWidth().toFixed(1) + "px";
nb.style.height = nbHeight() + "px";
nb.style.transform = "translate(" + nbLeft() + "px," + nbTop() + "px)";
gv.style.opacity = aV.toFixed(2);
gv.style.transform = "translateX(" + posV.toFixed(1) + "px)";
gh.style.opacity = aH.toFixed(2);
gh.style.transform = "translateY(" + posH.toFixed(1) + "px)";
gapEl.textContent = String(Math.round(nbLeft() - (GRID_STEP + bw)));
stepEl.textContent = String(GRID_STEP);
cursor.style.transform = "translate(" + px.toFixed(1) + "px," + py.toFixed(1) + "px)";
}
on(grip, "pointerdown", (e) => {
userTakes();
grip.setPointerCapture(e.pointerId);
const p = pos(e);
px = p.x;
py = p.y;
gx = GRID_STEP + bw - p.x;
gy = GRID_STEP + bh - p.y;
dragging = true;
});
on(grip, "pointermove", (e) => {
if (!dragging)
return;
const p = pos(e);
px = p.x;
py = p.y;
rawR = clamp(px + gx, GRID_STEP + MIN_SIDE, root.clientWidth);
rawB = clamp(py + gy, GRID_STEP + MIN_SIDE, root.clientHeight);
if (reduce)
resolve(0);
sync();
});
on(grip, "pointerup", () => { dragging = false; });
on(grip, "pointercancel", () => { dragging = false; });
on(grip, "keydown", (e) => {
const k = e.key;
if (k !== "ArrowLeft" && k !== "ArrowRight" && k !== "ArrowUp" && k !== "ArrowDown")
return;
e.preventDefault();
userTakes();
rawR = clamp(GRID_STEP + bw + (k === "ArrowRight" ? GRID_STEP : k === "ArrowLeft" ? -GRID_STEP : 0), GRID_STEP + MIN_SIDE, root.clientWidth);
rawB = clamp(GRID_STEP + bh + (k === "ArrowDown" ? GRID_STEP : k === "ArrowUp" ? -GRID_STEP : 0), GRID_STEP + MIN_SIDE, root.clientHeight);
resolve(0);
sync();
});
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
const dt = Math.min(0.05, (ts - last) / 1000);
last = ts;
step(dt, ts - t0);
render();
raf = requestAnimationFrame(frame);
}
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// GridSnapGuides - vanilla 판(08-rh-grid-snap-guides) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./GridSnapGuides.module.scss";
export default function GridSnapGuides() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
const offs: Array<() => void> = [];
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function stopDemo(): void {
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
}
function pos(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function spring(x: number, v: number, to: number, k: number, c: number, dt: number): number[] {
const n = Math.max(1, Math.ceil(dt / 0.008));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
v += (-k * (x - to) - c * v) * h;
x += v * h;
}
return [x, v];
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 가장자리가 격자선·이웃 가장자리에서 SNAP_DIST 안이면 거리에 비례해 끌려간다.
// 끌림은 v = c + (raw - c) * (d / SNAP_DIST) 라서 선 위에서는 평평하고 멀어질수록 손을 따라간다.
// 선에 거의 닿으면 안내선이 번쩍 켜지고 GUIDE_FADE 시간 상수로 사라진다.
const GRID_STEP = 24;
const SNAP_DIST = 12;
const GUIDE_FADE = 0.35;
const MIN_SIDE = 48;
const box = q<HTMLElement>("[data-r=box]");
const nb = q<HTMLElement>("[data-r=nb]");
const grip = q<HTMLElement>("[data-r=grip]");
const gv = q<HTMLElement>("[data-r=gv]");
const gh = q<HTMLElement>("[data-r=gh]");
const gapEl = q<HTMLElement>("[data-r=gap]");
const stepEl = q<HTMLElement>("[data-r=step]");
const cursor = q<HTMLElement>("[data-r=cursor]");
let bw = 0;
let bh = 0;
let rawR = 0;
let rawB = 0;
let px = 0;
let py = 0;
let gx = 0;
let gy = 0;
let aV = 0;
let aH = 0;
let posV = 0;
let posH = 0;
let dragging = false;
let ready = false;
root.style.setProperty("--step", GRID_STEP + "px");
function nbLeft(): number { return Math.floor(root.clientWidth * 0.62) + 7; }
function nbWidth(): number { return Math.max(56, root.clientWidth * 0.22); }
function nbTop(): number { return GRID_STEP + 5; }
function nbHeight(): number { return Math.floor(root.clientHeight * 0.4); }
function snapEdge(r: number, cands: number[]): number[] {
let best = cands[0];
for (let i = 1; i < cands.length; i += 1) {
if (Math.abs(cands[i] - r) < Math.abs(best - r)) best = cands[i];
}
const d = Math.abs(r - best);
if (d >= SNAP_DIST) return [r, 0, best];
return [best + (r - best) * (d / SNAP_DIST), d < SNAP_DIST * 0.4 ? 1 : 0, best];
}
function resolve(dt: number): void {
const gridX = Math.round(rawR / GRID_STEP) * GRID_STEP;
const gridY = Math.round(rawB / GRID_STEP) * GRID_STEP;
const sx = snapEdge(rawR, [gridX, nbLeft(), nbLeft() + nbWidth()]);
const sy = snapEdge(rawB, [gridY, nbTop() + nbHeight()]);
bw = Math.max(MIN_SIDE, sx[0] - GRID_STEP);
bh = Math.max(MIN_SIDE, sy[0] - GRID_STEP);
if (sx[1] === 1) { aV = 1; posV = sx[2]; } else aV *= Math.exp(-dt / GUIDE_FADE);
if (sy[1] === 1) { aH = 1; posH = sy[2]; } else aH *= Math.exp(-dt / GUIDE_FADE);
}
function init(): void {
if (ready) return;
ready = true;
rawR = root.clientWidth * 0.3;
rawB = root.clientHeight * 0.42;
resolve(0);
}
function userTakes(): void {
stopDemo();
dragging = false;
init();
}
function script(now: number): void {
const W = root.clientWidth;
const H = root.clientHeight;
const ph = now % 2000;
const x0 = W * 0.3;
const y0 = H * 0.42;
const x1 = W * 0.88;
const y1 = H * 0.9;
let t = 0;
if (ph >= 200 && ph < 1100) t = (ph - 200) / 900;
else if (ph >= 1100) t = 1 - (ph - 1100) / 900;
rawR = x0 + (x1 - x0) * t;
rawB = y0 + (y1 - y0) * t;
px = rawR - 8;
py = rawB - 8;
dragging = ph >= 200;
}
function snap(): void { init(); }
function step(dt: number, now: number): void {
init();
if (demo) script(now);
else if (dragging) {
rawR = clamp(px + gx, GRID_STEP + MIN_SIDE, root.clientWidth);
rawB = clamp(py + gy, GRID_STEP + MIN_SIDE, root.clientHeight);
}
resolve(dt);
}
function render(): void {
box.style.transform = "translate(" + GRID_STEP + "px," + GRID_STEP + "px)";
box.style.width = bw.toFixed(1) + "px";
box.style.height = bh.toFixed(1) + "px";
nb.style.width = nbWidth().toFixed(1) + "px";
nb.style.height = nbHeight() + "px";
nb.style.transform = "translate(" + nbLeft() + "px," + nbTop() + "px)";
gv.style.opacity = aV.toFixed(2);
gv.style.transform = "translateX(" + posV.toFixed(1) + "px)";
gh.style.opacity = aH.toFixed(2);
gh.style.transform = "translateY(" + posH.toFixed(1) + "px)";
gapEl.textContent = String(Math.round(nbLeft() - (GRID_STEP + bw)));
stepEl.textContent = String(GRID_STEP);
cursor.style.transform = "translate(" + px.toFixed(1) + "px," + py.toFixed(1) + "px)";
}
on(grip, "pointerdown", (e: PointerEvent) => {
userTakes();
grip.setPointerCapture(e.pointerId);
const p = pos(e);
px = p.x;
py = p.y;
gx = GRID_STEP + bw - p.x;
gy = GRID_STEP + bh - p.y;
dragging = true;
});
on(grip, "pointermove", (e: PointerEvent) => {
if (!dragging) return;
const p = pos(e);
px = p.x;
py = p.y;
rawR = clamp(px + gx, GRID_STEP + MIN_SIDE, root.clientWidth);
rawB = clamp(py + gy, GRID_STEP + MIN_SIDE, root.clientHeight);
if (reduce) resolve(0);
sync();
});
on(grip, "pointerup", () => { dragging = false; });
on(grip, "pointercancel", () => { dragging = false; });
on(grip, "keydown", (e: KeyboardEvent) => {
const k = e.key;
if (k !== "ArrowLeft" && k !== "ArrowRight" && k !== "ArrowUp" && k !== "ArrowDown") return;
e.preventDefault();
userTakes();
rawR = clamp(GRID_STEP + bw + (k === "ArrowRight" ? GRID_STEP : k === "ArrowLeft" ? -GRID_STEP : 0), GRID_STEP + MIN_SIDE, root.clientWidth);
rawB = clamp(GRID_STEP + bh + (k === "ArrowDown" ? GRID_STEP : k === "ArrowUp" ? -GRID_STEP : 0), GRID_STEP + MIN_SIDE, root.clientHeight);
resolve(0);
sync();
});
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
const dt = Math.min(0.05, (ts - last) / 1000);
last = ts;
step(dt, ts - t0);
render();
raf = requestAnimationFrame(frame);
}
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["rh"] + " is-demo"} data-demo="1">
<i className={styles["rh__grid"]}></i>
<i className={styles["rh__nb"]} data-r="nb"></i>
<div className={styles["rh__box"]} data-r="box">
<button className={styles["rh__grip"]} data-r="grip" type="button" aria-label="크기 조절 손잡이"></button>
</div>
<i className={styles["rh__gv"]} data-r="gv"></i>
<i className={styles["rh__gh"]} data-r="gh"></i>
<p className={styles["rh__meta"]}><span className={styles["rh__tag"]}>GAP</span> <b data-r="gap">0</b> <span className={styles["rh__tag"]}>STEP</span> <b data-r="step">0</b></p>
<i className={styles["rh__cursor"]} data-r="cursor"></i>
</div>
</main>
);
}08 A Handle Whose Guide Lines Flash When It Meets a Grid or Neighbor — Measured area 51.228% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Resize Handle: 9 Demos That Follow Your Grip
The post this demo lives in
CSS Resize Handle: 9 Demos That Follow Your GripA css resize handle left to the resize property alone cannot change its shape, ignores line height and just stops at the See all demos in this post
If you like this effect - similar demos
drag03 Grid reflow — inverting pointer coordinates into row/column indicesDrag to Reorder UI: 9 Lists, One Drop Index
drag04 Preview grid — reading aspect ratios and computing a gap-free layoutDrag and Drop File Upload UI: 9 Computed Dropzones
drag01 Grid-Magnet Drag That Pulls an Item Into a Cell Within RangeDrag and Drop Animation CSS: 9 Physics Patterns