07Sticky apply-bar reveal — a bottom bar repositioned by a selection-state value
Once at least one filter is selected, the bottom apply bar's vertical position interpolates up from off-screen, and it hides again once nothing is selected.
Best forThe apply-button bar in a mobile filter sheet, a commerce app's fixed bottom CTA
transformtranslateY(classList.toggle

<main class="stage" id="stage">
<div class="options">
<label class="option"><input class="option__input" type="checkbox" data-idx="0"> Free shipping</label>
<label class="option"><input class="option__input" type="checkbox" data-idx="1"> Same-day dispatch</label>
</div>
<div class="apply-bar is-demo" id="bar">
<span class="apply-bar__label">2 selected</span>
<button class="apply-bar__btn" type="button">Apply</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.stage {
position: relative;
overflow: hidden;
}
.options {
display: flex;
flex-direction: column;
gap: $sp-3;
}
.option {
display: flex;
align-items: center;
gap: $sp-2;
color: $subject-ink;
font-weight: 500;
font-size: 13px;
line-height: 1.4;
}
.option__input {
width: 16px;
height: 16px;
accent-color: $subject-ink;
}
.apply-bar {
box-sizing: border-box;
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-3 $sp-4;
background: $color;
color: $subject-ink;
box-shadow: $shadow-raised;
transform: translateY(100%);
transition: transform $duration $easing;
}
.apply-bar__label {
font-weight: 600;
font-size: 12px;
line-height: 1.3;
}
.apply-bar__btn {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-4;
background: $subject-ink;
color: $color;
font-family: inherit;
font-weight: 600;
font-size: 12px;
line-height: 1.3;
cursor: pointer;
}
.stage.has-selection .apply-bar {
transform: translateY(0);
}
.apply-bar.is-demo {
animation-name: bar-loop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes bar-loop {
0%, 15% { transform: translateY(100%); }
40%, 75% { transform: translateY(0); }
100% { transform: translateY(100%); }
}
@media (max-width: 340px) {
.apply-bar { padding: $sp-2 $sp-3; }
.options { gap: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.apply-bar { transition: none; }
.apply-bar.is-demo { animation: none; transform: translateY(0); opacity: .96; }
}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);
}
.stage {
position: relative;
overflow: hidden;
}
.options {
display: flex;
flex-direction: column;
gap: 12px;
}
.option {
display: flex;
align-items: center;
gap: 8px;
color: #17141a;
font-weight: 500;
font-size: 13px;
line-height: 1.4;
}
.option__input {
width: 16px;
height: 16px;
accent-color: #17141a;
}
.apply-bar {
box-sizing: border-box;
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateY(100%);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.apply-bar__label {
font-weight: 600;
font-size: 12px;
line-height: 1.3;
}
.apply-bar__btn {
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 600;
font-size: 12px;
line-height: 1.3;
cursor: pointer;
}
.stage.has-selection .apply-bar {
transform: translateY(0);
}
.apply-bar.is-demo {
animation-name: bar-loop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes bar-loop {
0%, 15% {
transform: translateY(100%);
}
40%, 75% {
transform: translateY(0);
}
100% {
transform: translateY(100%);
}
}
@media (max-width: 340px) {
.apply-bar {
padding: 8px 12px;
}
.options {
gap: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.apply-bar {
transition: none;
}
.apply-bar.is-demo {
animation: none;
transform: translateY(0);
opacity: 0.96;
}
}// 필터가 하나라도 선택되어 있는지(불 값)만 보고 바 위치를 translateY 로 보간한다.
// 실제 체크박스 변경: classList.toggle 로 has-selection 상태를 뒤집는다.
(function () {
var stage = document.getElementById('stage');
var bar = document.getElementById('bar');
var inputs = document.querySelectorAll('.option__input');
if (!stage || !bar || !inputs.length) return;
function sync() {
bar.classList.remove('is-demo');
var any = Array.prototype.some.call(inputs, function (input) {
return input.checked;
});
stage.classList.toggle('has-selection', any);
}
inputs.forEach(function (input) {
input.addEventListener('change', sync);
});
})();import { useEffect, useRef } from "react";
import styles from "./StickyApplyBarReveal.module.scss";
// 필터가 하나라도 선택되어 있는지(불 값)만 보고 바 위치를 translateY 로 보간한다.
// 실제 체크박스 변경: classList.toggle 로 has-selection 상태를 뒤집는다.
export default function StickyApplyBarReveal() {
const stageRef = useRef<HTMLElement>(null);
const barRef = useRef<HTMLDivElement>(null);
const input0Ref = useRef<HTMLInputElement>(null);
const input1Ref = useRef<HTMLInputElement>(null);
useEffect(() => {
const stage = stageRef.current;
const bar = barRef.current;
const inputs = [input0Ref.current, input1Ref.current].filter(
(x): x is HTMLInputElement => x !== null
);
if (!stage || !bar || inputs.length === 0) return;
function sync() {
bar!.classList.remove(styles["is-demo"]);
const anySelected = inputs.some((input) => input.checked);
stage!.classList.toggle(styles["has-selection"], anySelected);
}
inputs.forEach((input) => input.addEventListener("change", sync));
return () => {
inputs.forEach((input) => input.removeEventListener("change", sync));
};
}, []);
return (
<main ref={stageRef} className={styles.stage}>
<div className={styles.options}>
<label className={styles.option}>
<input ref={input0Ref} className={styles.option__input} type="checkbox" /> 무료 배송
</label>
<label className={styles.option}>
<input ref={input1Ref} className={styles.option__input} type="checkbox" /> 당일 출고
</label>
</div>
<div ref={barRef} className={`${styles["apply-bar"]} ${styles["is-demo"]}`}>
<span className={styles["apply-bar__label"]}>2개 선택됨</span>
<button className={styles["apply-bar__btn"]} type="button">
적용하기
</button>
</div>
</main>
);
}07 Sticky apply-bar reveal — a bottom bar repositioned by a selection-state value — Measured area 19.0% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · 9 Filter UI Patterns (Panel Slide to Chip Remove)
The post this demo lives in
9 Filter UI Patterns (Panel Slide to Chip Remove)Filter ui hides several small jobs. One of nine recomputes chip-row width once a chip disappears, one scales a badge by See all demos in this post
If you like this effect - similar demos
Transitions07 Checkout bar lift — a bottom bar whose shadow and height values grow with scroll positionCart Drawer UI Patterns: 9 Motion Techniques
Transitions04 Center-lift scale toggle — when the middle tab's selection value turns on, only that spot's height value grows above the backgroundCSS Bottom Navigation Bar: 9 Transition Patterns
Transitions09 Bottom tab bar9 CSS Sidebar Animations — Minimal JS