08글자 수 카운트
글자를 입력할 때마다 숫자가 바뀌면서 짧게 튕겨, 몇 자 남았는지를 조용히 알려줍니다.
쓰임자기소개 textarea, 트윗·리뷰처럼 길이 제한이 있는 입력창
maxlengthscale bump140ms

<main class="stage">
<div class="cc is-demo">
<label class="cc__label" for="cc-bio">소개글</label>
<textarea id="cc-bio" class="cc__input" maxlength="40" placeholder="자기소개를 40자 이내로 적어주세요" rows="2"></textarea>
<span class="cc__count" aria-live="polite"><b class="cc__num">0</b>/40</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-instant; // 숫자가 튕기는 시간
$easing: $ease-pop;
$color: $subject-blue; // 숫자·포커스 색
// ── 스프링 감쇠 반동 계수 ────────────────────────────────
// 진폭이 매 반동마다 γ=.42 배로 줄어든다 — 1 기준 진폭 .35 → -.147 → +.062
$b1: 1.35; // 첫 최대 진폭 (진폭 +.35)
$b2: 1 - .35 * .42; // 1회 반동(반대 방향, 진폭 -.147) → .853
$b3: 1 + .35 * .42 * .42; // 2회 반동(제자리 방향, 진폭 +.062) → 1.062
@include stage($stage-paper);
.cc { width: min(240px, 82vw); }
.cc__label { display: block; margin-bottom: $sp-2; font-weight: 700; font-size: 13px; line-height: 1; font-family: inherit; color: rgba($stage-ink, .75); }
.cc__input {
box-sizing: border-box;
width: 100%;
resize: none;
border: 2px solid rgba($stage-ink, .2);
border-radius: $r-control;
padding: $sp-3 $sp-3;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 14px);
line-height: 1.4;
font-family: inherit;
color: $stage-ink;
background: $subject-cream;
&:focus { outline: none; border-color: $color; }
}
.cc__count {
display: block;
margin-top: $sp-2;
text-align: right;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: rgba($stage-ink, .72); // 흐린 글자 알파 하한 .72 — 흰 면 위 7.27:1
}
.cc__num { color: $color; display: inline-block; transform: scale(1); }
.cc__count.is-bump .cc__num { animation: bump $duration $easing; }
@keyframes bump {
// 스프링 오버슛 — 튕긴 뒤 반대 방향 반동 2번을 거쳐 멎는다 (감쇠 진폭 1.35 → .853 → 1.062 → 1)
0% { transform: scale(1); }
35% { transform: scale(#{$b1}); }
60% { transform: scale(1); }
78% { transform: scale(#{$b2}); }
90% { transform: scale(#{$b3}); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.cc__count.is-bump .cc__num { animation: 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);
}
.cc {
width: min(240px, 82vw);
}
.cc__label {
display: block;
margin-bottom: 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.75);
}
.cc__input {
box-sizing: border-box;
width: 100%;
resize: none;
border: 2px solid rgba(23, 20, 26, 0.2);
border-radius: 12px;
padding: 12px 12px;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 14px);
line-height: 1.4;
font-family: inherit;
color: #17141a;
background: #fff7e6;
}
.cc__input:focus {
outline: none;
border-color: #2f6df6;
}
.cc__count {
display: block;
margin-top: 8px;
text-align: right;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.cc__num {
color: #2f6df6;
display: inline-block;
transform: scale(1);
}
.cc__count.is-bump .cc__num {
animation: bump 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes bump {
0% {
transform: scale(1);
}
35% {
transform: scale(1.35);
}
60% {
transform: scale(1);
}
78% {
transform: scale(0.853);
}
90% {
transform: scale(1.06174);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.cc__count.is-bump .cc__num {
animation: none;
}
}// 실사용: 입력할 때마다 남은 글자 수를 세고 튕긴다. 데모(.is-demo)는 값을 흉내내 반복 (9줄)
var ta = document.querySelector('.is-demo .cc__input');
var num = document.querySelector('.is-demo .cc__num');
var texts = ['', '안녕하세요', '안녕하세요 저는 개발자입니다'];
var i = 0;
var t = function () {
ta.value = texts[i % texts.length]; i += 1;
num.textContent = String(ta.value.length);
num.parentElement.classList.remove('is-bump'); void num.offsetWidth; num.parentElement.classList.add('is-bump');
};
t(); setInterval(t, 450);import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./CharCount.module.scss";
export type CharCountProps = {
id?: string;
label?: string;
placeholder?: string;
maxLength?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CharCount({
id = "cc-bio",
label = "Bio",
placeholder = "Write a short bio (40 characters max)",
maxLength = 40,
duration = 140,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: CharCountProps) {
const [value, setValue] = useState("");
const [bump, setBump] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
setBump(false);
requestAnimationFrame(() => setBump(true));
};
return (
<div className={styles.cc} style={vars}>
<label htmlFor={id} className={styles.label}>{label}</label>
<textarea
id={id}
className={styles.input}
maxLength={maxLength}
placeholder={placeholder}
rows={2}
value={value}
onChange={onChange}
/>
<span className={styles.count} aria-live="polite">
<b className={bump ? `${styles.num} ${styles.bump}` : styles.num} onAnimationEnd={() => setBump(false)}>
{value.length}
</b>
/{maxLength}
</span>
</div>
);
}08 글자 수 카운트 — 측정 누적면적 1.504% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 입력창 검색창 포커스 애니메이션 9종 — 한 줄 복붙으로
이 데모가 속한 글
CSS 입력창 검색창 포커스 애니메이션 9종 — 한 줄 복붙으로CSS 입력창 애니메이션은 클릭하기 전·클릭한 순간·값을 다 채운 뒤까지 브라우저가 이미 쥐고 있는 :focus :focus-within :placeholder-shown :valid :invalid 상태를 그대로 이 글의 데모 전부 보기


