08Đếm số ký tự
Mỗi lần gõ, con số cập nhật và nảy nhẹ một nhịp, âm thầm báo còn lại bao nhiêu ký tự.
Dùng choTextarea giới thiệu bản thân, ô giới hạn độ dài như tweet hoặc review
maxlengthscale bump140ms

<main class="stage">
<div class="cc is-demo">
<label class="cc__label" for="cc-bio">Tiểu sử</label>
<textarea id="cc-bio" class="cc__input" maxlength="40" placeholder="Giới thiệu ngắn (tối đa 40 ký tự)" 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 = ['', 'Xin chào', 'Xin chào, tôi là dev'];
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 Đếm số ký tự — Diện tích đo được 1.504% · khung hình chuyển động 10/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 CSS Input Focus Animation — dán 1 dòng là chạy
Bài viết chứa demo này
9 CSS Input Focus Animation — dán 1 dòng là chạyCSS input focus animation là các hiệu ứng dùng thẳng trạng thái trình duyệt đã có sẵn — :focus, :focus-within, :placehol Xem tất cả demo trong bài này


