๐ฑ๏ธ Tailwind 6์ฅ: ์ํ ๊ธฐ๋ฐ ์คํ์ผ๋ง
๐ ๊ฐ์
Tailwind variant ์์คํ ์์ ์ ๋ณต โ ์ฌ์ฉ์ ์ธํฐ๋์ ์ ๋ฐ์ํ๋ UI ๋ง๋ค๊ธฐ
๐ ๋ชฉ์ฐจ
- ๐ ์ด ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์
- ๐ค ์ ์์์ผ ํ๋๊ฐ
- ๐งฉ Variant ์์คํ ๊ฐ์
- ๐ฑ๏ธ ๊ธฐ๋ณธ ์ํ Variant (hover, focus, active)
- ๐ช Group Variant: ๋ถ๋ชจ ์ํ๋ก ์์ ์ ์ด
- ๐ค Peer Variant: ํ์ ์ํ๋ก ์ ์ด
- ๐ป ์ค์ : ์์๋ค ์ปค๋ฎค๋ํฐ ์ธํฐ๋ํฐ๋ธ UI
- ๐ง ๊ณ ๊ธ Variant ์คํํน
- ๐ ์ด๋ฒ์ ๋ฐฐ์ด ๋ด์ฉ ์ด์ ๋ฆฌ
- ๐ ๋ง๋ฌด๋ฆฌ ํด์ฆ
- ๐ฃ ์์ฒ ์ด์ ํด๊ทผ ์ผ๊ธฐ
- ๐ ๋ ์์๋ณด๊ธฐ
๐ ์ด ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์
โฑ๏ธ ์์ ์ฝ๊ธฐ ์๊ฐ: 18๋ถ
๐ฏ ์ด ๋ฌธ์๋ฅผ ๋ค ์ฝ์ผ๋ฉด ํ ์ ์๋ ๊ฒ
-
hover:,focus:,active:,disabled:๊ฐ์ ๊ธฐ๋ณธ ์ํ variant ๋ฅผ ์์ ์์ฌ๋ก ์ธ ์ ์๋ค -
group/group-hover:๋ก ๋ถ๋ชจ hover ์ ์์ ์คํ์ผ์ ์ ์ดํ ์ ์๋ค -
peer/peer-invalid:๋ก ํ์ ์์ ์ํ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์คํ์ผ์ ์ ์ดํ ์ ์๋ค
๐บ๏ธ ์ด ๋ฌธ์์ ๋ฐฐ๊ฒฝ ์ธ๊ณ๊ด: '์์๋ค ์ปค๋ฎค๋ํฐ'
- ๐จ ์์ (๋์์ด๋): "์์ฒ ๋, ์คํฐ๋ ์นด๋์ hover ํ๋ฉด ์นด๋ ์ ์ฒด๊ฐ ์ด์ง ์ฌ๋ผ์ค๊ณ , ์นด๋ ์ ๋ชฉ ์์ด ํ๋๊ฒ ๋ณํ๋ฉด ์ข๊ฒ ์ด์. ๊ทธ๋ฆฌ๊ณ ๊ทธ ์์ ๋ฒํผ๋ hover ์ ์์ด ๋ฌ๋ผ์ ธ์ผ ํ๊ณ ์."
- ๐ฃ ์์ฒ : "์ด... JS ๋ก ์ํ ๊ด๋ฆฌํด์ ํด๋์ค๋ฅผ ๋์ ์ผ๋ก ๊ต์ฒดํด์ผ ํ๋์?"
- ๐ฆ ์ํธ (๋ฆฌ๋): "๊ทธ๋ด ํ์ ์์ด์. Tailwind ์
group๊ณผgroup-hover:๋ฅผ ์ฐ๋ฉด JS ์์ด CSS ๋ง์ผ๋ก ํด๊ฒฐ๋ผ์." - ๐ฃ ์์ฒ : "JS ์์ด์?!"
๐ค ์ ์์์ผ ํ๋๊ฐ
์ธํฐ๋ํฐ๋ธํ UI ๋ ๋จ์ํ "์ด์ UI" ๊ฐ ์๋์ผ. ์ฌ์ฉ์์๊ฒ "์ด ์์๋ฅผ ํด๋ฆญํ ์ ์๋ค", "์ง๊ธ ํฌ์ปค์ค๊ฐ ์ฌ๊ธฐ ์๋ค" ๋ ํผ๋๋ฐฑ์ ์ค์ ์ ๊ทผ์ฑ๊ณผ UX ๋ฅผ ๋์ด๋ ํต์ฌ ์์์ผ.
์ ํต CSS ์์๋ ์ด๊ฑธ ์ํด ๋ณ๋ CSS ํ์ผ์ :hover, :focus ๊ท์น์ ์์ฑํ๊ฑฐ๋, JS ๋ก ํด๋์ค๋ฅผ ๋์ ์ผ๋ก ์ถ๊ฐ/์ ๊ฑฐํ์ด. ํ์ง๋ง Tailwind ์ variant ์์คํ
์ ์ฐ๋ฉด HTML ํด๋์ค๋ง์ผ๋ก ์์ฑ๋ผ.
๐งฉ Variant ์์คํ ๊ฐ์
Tailwind ์ ๋ชจ๋ ์ ํธ๋ฆฌํฐ ํด๋์ค๋ ์์ variant ๋ฅผ ๋ถ์ฌ์ ์กฐ๊ฑด๋ถ๋ก ์ ์ฉํ ์ ์์ด.
{variant}:{utility}
์:
hover:bg-blue-700 โ :hover ์ํ์์ bg-blue-700 ์ ์ฉ
focus:ring-2 โ :focus ์ํ์์ ring-2 ์ ์ฉ
md:flex โ 768px ์ด์์์ flex ์ ์ฉ
dark:bg-gray-800 โ ๋คํฌ ๋ชจ๋์์ bg-gray-800 ์ ์ฉ
๐ฑ๏ธ ๊ธฐ๋ณธ ์ํ Variant (hover, focus, active)
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
hover:,focus:,active:,disabled:๊ฐ๊ฐ์ ์ ํํ ํธ๋ฆฌ๊ฑฐ ํ์ด๋ฐ์ ๊ตฌ๋ถํ ์ ์๋ค- ์ ๊ทผ์ฑ์ ํด์น์ง ์๋
focus์คํ์ผ๋ง ํจํด์ ์ ์ ์๋คfirst:,last:,odd:,even:๊ฐ์ ๊ตฌ์กฐ์ Variant ๋ก ๋ฐ๋ณต UI ๋ฅผ ์ฐ์ํ๊ฒ ์ฒ๋ฆฌํ ์ ์๋ค
๐ฑ๏ธ Hover โ ๋ง์ฐ์ค๊ฐ ์ฌ๋ผ์์ ๋
์์๋ค ์ปค๋ฎค๋ํฐ ์คํฐ๋ ๋ชฉ๋ก์ ๋ง๋ค๋ ์์ฒ ์ด๊ฐ ์ฒ์ ๋ถ๋ชํ ์ํ ์คํ์ผ๋ง์ด ๋ฐ๋ก hover ์ผ. ๋ฒํผ์ ๋ง์ฐ์ค๋ฅผ ์ฌ๋ ธ์ ๋ ์์ด ๋ฐ๋๊ณ , ์นด๋์ ์ฌ๋ ธ์ ๋ ๊ทธ๋ฆผ์๊ฐ ์งํด์ง๋ ๊ทธ ํจ๊ณผ ๋ง์ด์ผ.
๐ฆ ์ํธ ๋ฆฌ๋ ๋์ ๋ง: "hover ๋ :hover ์์ฌ ํด๋์ค๋ฅผ Tailwind ๊ฐ ํด๋์ค๋ก ์ถ์ํํ ๊ฒ๋ฟ์ด์ผ. hover:bg-blue-700 ์ CSS ๋ก .hover\:bg-blue-700:hover { background-color: ... } ๋ฅผ ์์ฑํด์. ์๋ฆฌ๋ฅผ ์๋ฉด ์์ฉ์ด ์ฌ์์ ธ์."
<!-- ๐ฃ ์์ฒ 1์: JS ๋ก ํด๋์ค ํ ๊ธํด์ hover ๊ตฌํ โ ๋ณต์กํ๊ณ ๋ถํ์ -->
<button id="btn">ํด๋ฆญ</button>
<script>
btn.addEventListener('mouseover', () => btn.classList.add('bg-blue-700'))
btn.addEventListener('mouseout', () => btn.classList.remove('bg-blue-700'))
</script>
<!-- ๐ฆ ์ํธ ๋ฆฌํฉํ ๋ง: Tailwind variant ํ๋๋ก ๋ -->
<button class="bg-blue-600 hover:bg-blue-700 transition-colors">ํด๋ฆญ</button>hover: ๋ ๋จ์ํ ์์ ๋ณ๊ฒฝ์๋ง ์ฐ์ด๋ ๊ฒ ์๋์ผ. transform, shadow, opacity ๋ฑ ์ด๋ค ์ ํธ๋ฆฌํฐ๋ ์์ hover: ๋ฅผ ๋ถ์ด๋ฉด hover ์์๋ง ์ ์ฉ๋ผ.
<!-- ์นด๋ hover ์ ๊ทธ๋ฆผ์ ๊ฐ์กฐ + ์ด์ง ์๋ก ์ด๋ -->
<div class="shadow-md transition-all hover:-translate-y-1 hover:shadow-xl">์นด๋</div>
<!-- ๋งํฌ hover ์ ๋ฐ์ค (๊ธฐ๋ณธ ๋ฐ์ค ์ ๊ฑฐ ํ hover ์๋ง ํ์) -->
<a class="no-underline hover:underline">๋งํฌ</a>๐ฏ Focus โ ํค๋ณด๋ ํฌ์ปค์ค์ ์ ๊ทผ์ฑ์ ๊ท ํ
ํฌ์ปค์ค ์คํ์ผ์ ๋จ์ํ ๋ฏธ์ ์์๊ฐ ์๋์ผ. Tab ํค๋ก ํ์ด์ง๋ฅผ ํ์ํ๋ ํค๋ณด๋ ์ฌ์ฉ์ ์ ์คํฌ๋ฆฐ ๋ฆฌ๋ ์ฌ์ฉ์ ์๊ฒ "์ง๊ธ ํฌ์ปค์ค๊ฐ ์ฌ๊ธฐ ์์ด์" ๋ฅผ ์๋ ค์ฃผ๋ ์ ๊ทผ์ฑ์ ํต์ฌ ์ด์ผ.
์์์ด ์ฝ๋ ๋ฆฌ๋ทฐ ์ค์ ์ด๋ฐ ๋ง์ ํ์ด:
๐จ ์์ ๋: "์์ฒ ๋, ๋ก๊ทธ์ธ ํผ์์ Tab ํค๋ก ์ด๋ํ๋ฉด ์ด๋์ ํฌ์ปค์ค๊ฐ ์๋์ง๊ฐ ์ ๋ณด์ฌ์. ์คํฌ๋ฆฐ ๋ฆฌ๋ ์ฐ๋ ๋ถ๋ค์ ์ด๋ป๊ฒ ํ๋ผ๊ณ ์?"
๐ฃ ์์ฒ : "์, outline: none ์ผ๋๋ ํ๋ ํ
๋๋ฆฌ๊ฐ ๋๋ฌด ๋ชป์๊ฒจ์..."
๐ฆ ์ํธ: "๊ทธ๊ฑฐ ์ ๊ทผ์ฑ ์๋ฐ์ด์์. focus:outline-none ๋ง ์ฐ๋ฉด WCAG 2.1 ๊ธฐ์ค ๋ฏธ๋ฌ์ด์ผ. ๋์ focus-visible: ์ ์จ์."
<!-- โ ์์งํ ์ฝ๋: outline ์ ๊ฑฐ๋ง ํ๊ณ ๋์ ์์ โ ์ ๊ทผ์ฑ ์๋ฐ -->
<button class="focus:outline-none">๋ฒํผ</button>
<!-- โ
์ฌ๋ฐ๋ฅธ ์ฝ๋: outline ์ ๊ฑฐ + ring ์ผ๋ก ๋์ ํฌ์ปค์ค ์คํ์ผ ์ ๊ณต -->
<input class="
rounded-lg border border-gray-300 px-4 py-2
focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20
" />
<!-- ๐ก ๋ ์ ๊ตํ๊ฒ: focus-visible ๋ก ํค๋ณด๋ ํฌ์ปค์ค๋ง ์คํ์ผ๋ง -->
<!-- focus-visible: = ํค๋ณด๋ ํฌ์ปค์ค์ผ ๋๋ง (๋ง์ฐ์ค ํด๋ฆญ ์์๋ ์ ์ฉ ์ ๋จ) -->
<button class="focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2">
๋ฒํผ
</button>โ ๏ธ ์ ๊ทผ์ฑ ํฉ๊ธ ๊ณต์:
focus:outline-none์ ๋ฐ๋์focus-visible:ring-*์ ์ง์ ์ด๋ค์ผ ํ๋ค. ํผ์ ์ฐ๋ฉด ํค๋ณด๋ ์ฌ์ฉ์๊ฐ ํฌ์ปค์ค๋ฅผ ์๊ฐ์ ์ผ๋ก ํ์ ํ ์ ์์ด WCAG 2.1 2.4.7 ์๋ฐ ์ด์ผ.
๐ Active โ ํด๋ฆญํ๋ ๊ทธ ์ฐฐ๋์ ์๊ฐ
active: ๋ ๋ง์ฐ์ค ๋ฒํผ์ ๋๋ฅด๊ณ ์๋ ๋์ ๋ง ์ ์ฉ๋ผ. ํด๋ฆญ ์ ๋๋ฉ์ด์
์ ์ฐ๋ฉด ๋ฌผ๋ฆฌ์ ์ผ๋ก ๋ฒํผ์ ๋๋ฅด๋ ๋๋์ ์ค ์ ์์ด.
๐จ ์์ ๋: "๋ฒํผ ํด๋ฆญํ ๋ ์ด์ง ๋๋ฆฌ๋ ๋๋ ์์ผ๋ฉด ์ข๊ฒ ์ด์. ์์ฆ ์ฑ๋ค์ฒ๋ผ์."
<!-- ํด๋ฆญ ์ ์ด์ง ์ถ์ + ์ ์งํด์ง๋ ํผ์ง์ปฌ ๋๋ -->
<button class="
transform bg-blue-600 text-white px-6 py-3 rounded-xl
transition-transform duration-75
active:scale-95 active:bg-blue-800
">
์คํฐ๋ ์ฐธ์ฌํ๊ธฐ
</button>transition-transform duration-75 ๋ 75ms ์งง์ ์ ํ ์๊ฐ โ ๋๋ฌด ๊ธธ๋ฉด ํด๋ฆญ ๋ฐ์์ด ๋๋ ค ๋ณด์ฌ์ duration-75 ~ duration-150 ์ ๋๊ฐ ์ ๋นํด.
๐ซ Disabled โ ๋นํ์ฑํ ์ํ์ UX
๋นํ์ฑํ๋ ๋ฒํผ์ "์ง๊ธ์ ๋ชป ๋๋ฌ" ๋ผ๋ ์ ํธ์ผ. ์์ฒ ์ด๊ฐ ์คํฐ๋ ๋ง๊ฐ ๊ธฐ๋ฅ์ ๋ง๋ค ๋ ์ด ํจํด์ ์ผ์ด.
<!-- โ
disabled ์์ฑ + Tailwind disabled: variant ์กฐํฉ -->
<button class="
bg-blue-600 text-white px-6 py-3 rounded-xl
disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-gray-400
transition-colors
" disabled>
๋ง๊ฐ๋ ์คํฐ๋
</button>๐ก
disabled:๋ HTMLdisabled์์ฑ์ด ์์ ๋๋ง ๋์ํด. CSS ์์:disabled์์ฌ ํด๋์ค๋ฅผ ์ฌ์ฉํ๋ ๊ฒ๊ณผ ๋์ผํด. React ์์๋<button disabled={isDisabled}>์ฒ๋ผ prop ์ผ๋ก ์ ์ดํ๋ฉด ๋ผ.
๐ข ๊ตฌ์กฐ์ Variant โ first, last, odd, even
์คํฐ๋ ๋ชฉ๋ก์ฒ๋ผ ๋์ผํ ํญ๋ชฉ์ด ๋ฐ๋ณต๋ ๋, ๊ฐ ํญ๋ชฉ์ ๊ฐ๋ณ ํด๋์ค๋ฅผ ์ถ๊ฐํ์ง ์๊ณ ๊ตฌ์กฐ์ ์์น ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์คํ์ผ๋งํ ์ ์์ด.
์์ฒ ์ด๊ฐ ์ฒ์์ ์ด๋ฐ ์ฝ๋๋ฅผ ์ผ์ด:
// ๐ฃ ์์ฒ 1์: ์ฒซ ๋ฒ์งธ ํญ๋ชฉ์ pt-0, ๋ง์ง๋ง์ border-b ์์ ์ผ ํด์ index ๋ก ์กฐ๊ฑด ์ฒ๋ฆฌ
{studies.map((study, index) => (
<li
key={study.id}
className={`py-3 border-b border-gray-200 ${index === 0 ? 'pt-0' : ''} ${index === studies.length - 1 ? 'border-b-0' : ''}`}
>
{study.title}
</li>
))}๐ฆ ์ํธ: "์์ฒ ๋, first: ๋ last: ์ฐ๋ฉด index ๊ณ์ฐ ํ์ ์์ด์. Tailwind ๊ฐ CSS ์ :first-child, :last-child ๋ฅผ variant ๋ก ์ถ์ํํ ๊ฑฐ๊ฑฐ๋ ์."
// ๐ฆ ์ํธ ๋ฆฌํฉํ ๋ง: ๊ตฌ์กฐ์ Variant ๋ก ๊น๋ํ๊ฒ
{studies.map((study) => (
<li
key={study.id}
className="border-b border-gray-200 py-3 first:pt-0 last:border-b-0"
>
{study.title}
</li>
))}odd: / even: ์ ํ
์ด๋ธ์ด๋ ๋ชฉ๋ก์์ ์ค๋ฌด๋ฌ(zebra stripe) ํจํด์ ๋ง๋ค ๋ ์ ์ฉํด:
<!-- ํ
์ด๋ธ ํ ์ค๋ฌด๋ฌ: ํ์ ํ์ ํฐ์, ์ง์ ํ์ ํ์ -->
<tr class="odd:bg-white even:bg-gray-50">ํ</tr>
<!-- placeholder ์์ ์ ์ด -->
<input class="placeholder:text-gray-400 focus:placeholder:text-gray-300" />
<!-- ์ฒดํฌ๋ ์ํ -->
<input type="checkbox" class="checked:bg-blue-600 checked:border-blue-600" />๐ช Group Variant: ๋ถ๋ชจ ์ํ๋ก ์์ ์ ์ด
group ์ ๋ถ๋ชจ์ ๋ถ์ด๋ฉด, ๊ทธ ์์๋ค์ด ๋ถ๋ชจ์ ์ํ๋ฅผ ๊ฐ์งํด์ group-hover:, group-focus: ๋ฑ์ ์ฌ์ฉํ ์ ์์ด.
<!-- ๋ถ๋ชจ์ group ๋ถ์ด๊ธฐ -->
<div class="group">
<h3 class="text-gray-900 group-hover:text-blue-600">
์นด๋ ์ ๋ชฉ (๋ถ๋ชจ hover ์ ํ๋์์ผ๋ก)
</h3>
<p class="text-gray-500 group-hover:text-gray-700">
์ค๋ช
(๋ถ๋ชจ hover ์ ์ข ๋ ์งํ๊ฒ)
</p>
<button class="opacity-0 group-hover:opacity-100 transition-opacity">
๋ฒํผ (๋ถ๋ชจ hover ์ ๋ํ๋จ)
</button>
</div>์ค์ : ์์๋ค ์ปค๋ฎค๋ํฐ ์คํฐ๋ ์นด๋
// ๐ฆ ์ํธ: "์นด๋ ์ ์ฒด์ hover ๋ฅผ ๊ฑธ๊ณ , ๋ด๋ถ ์์๋ค์ด ๋ฐ๋ก ๋ฐ์ํ๊ฒ ํ๋ ค๋ฉด
// group ์ด ์ ๋ต์ด์์. JS ์์ด CSS ๋ง์ผ๋ก!"
function StudyCard({ title, description, memberCount, status }: Props) {
return (
// group: ์ด div ๊ฐ group ์ ๊ธฐ์ค์
// hover:-translate-y-1: hover ์ 1px ์๋ก ์ด๋
// hover:shadow-lg: hover ์ ๊ทธ๋ฆผ์ ๊ฐ์กฐ
<div className="group relative rounded-xl border border-gray-200 bg-white p-5 shadow-md transition-all hover:-translate-y-1 hover:shadow-lg">
{/* ์ํ ๋ฑ์ง: ํญ์ ์ค๋ฅธ์ชฝ ์๋จ์ */}
<div className="absolute right-4 top-4">
<StatusBadge status={status} />
</div>
{/* ์์ด์ฝ: hover ์ ํ๋์ ๋ฐฐ๊ฒฝ์ผ๋ก */}
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-xl transition-colors group-hover:bg-blue-100">
๐
</div>
{/* ์ ๋ชฉ: hover ์ ํ๋์์ผ๋ก */}
<h3 className="mb-2 text-lg font-bold text-gray-900 transition-colors group-hover:text-blue-600">
{title}
</h3>
{/* ์ค๋ช
: hover ์ ์ฝ๊ฐ ์งํ๊ฒ */}
<p className="mb-4 line-clamp-2 text-sm text-gray-500 transition-colors group-hover:text-gray-700">
{description}
</p>
{/* ํ๋จ ์ ๋ณด */}
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">๐ฅ {memberCount}๋ช
</span>
{/* ๋ฒํผ: ๊ธฐ๋ณธ ์จ๊น โ hover ์ ๋ํ๋จ */}
<button className="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white opacity-0 transition-opacity group-hover:opacity-100">
์์ธํ ๋ณด๊ธฐ โ
</button>
</div>
</div>
);
}์ค์ฒฉ Group (๊ทธ๋ฃน ์ด๋ฆ ์ง์ )
์ฌ๋ฌ ๊ฐ์ group ์ด ์ค์ฒฉ๋ ๋ ์ด๋ฆ์ ์ง์ ํด์ ๊ตฌ๋ถํ ์ ์์ด.
<!-- ์ค์ฒฉ group ์์ ํน์ group ๋ง ๋ฐ์ํ๊ฒ ํ๊ธฐ -->
<div class="group/card">
<div class="group/btn">
<button class="group-hover/card:bg-blue-50 group-hover/btn:bg-blue-100">
์นด๋ hover ์ ํ๋ ๋ฐฐ๊ฒฝ, ๋ฒํผ ์์ฒด hover ์ ๋ ์งํ ํ๋ ๋ฐฐ๊ฒฝ
</button>
</div>
</div>๐ค Peer Variant: ํ์ ์ํ๋ก ์ ์ด
peer ๋ฅผ ํ์ ์์์ ๋ถ์ด๋ฉด, ๊ทธ ๋ค์ ์ค๋ ํ์ ๋ค์ด peer-hover:, peer-invalid:, peer-checked: ๋ฑ์ ์ฌ์ฉํด์ peer ์ ์ํ์ ๋ฐ์ํ ์ ์์ด.
โ ๏ธ ์ค์:
peer์ ๊ทธ๊ฑธ ๊ฐ์งํ๋ ์์๋ ํ์ ๊ด๊ณ์ฌ์ผ ํ๊ณ , peer ๊ฐ ์์ ์์ผ ํด (CSS ์ ์ผ๋ฐ ํ์ ์ ํ์~๋ฐฉํฅ ๋๋ฌธ).
<!-- ์
๋ ฅ ํ๋๊ฐ peer, ์๋ฌ ๋ฉ์์ง๊ฐ peer-invalid ์ ๋ฐ์ -->
<div>
<input
type="email"
class="peer rounded-lg border border-gray-300 px-4 py-2
invalid:border-red-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
placeholder="์ด๋ฉ์ผ ์
๋ ฅ"
required
/>
<!-- peer-invalid: input ์ด invalid ์ํ์ผ ๋๋ง ๋ณด์ -->
<p class="mt-1 hidden text-sm text-red-500 peer-invalid:block">
์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ์ฃผ์๋ฅผ ์
๋ ฅํด์ฃผ์ธ์
</p>
</div>์ค์ : ์ฒดํฌ๋ฐ์ค + ๋ผ๋ฒจ ์คํ์ผ๋ง
// ์ปค์คํ
์ฒดํฌ๋ฐ์ค โ peer ๋ก ์ฒดํฌ ์ํ๋ฅผ ๋ผ๋ฒจ์ ๋ฐ์
function CheckboxItem({ id, label }: { id: string; label: string }) {
return (
<label
htmlFor={id}
className="flex cursor-pointer items-center gap-3 rounded-lg p-3 hover:bg-gray-50"
>
{/* peer: ์ด ์ฒดํฌ๋ฐ์ค์ ์ํ๋ฅผ ํ์ ๋ค์ด ๊ฐ์ง */}
<input
id={id}
type="checkbox"
className="peer sr-only" {/* sr-only: ์๊ฐ์ ์ผ๋ก ์จ๊ธฐ๋ ์คํฌ๋ฆฐ ๋ฆฌ๋์๋ ๋ณด์ */}
/>
{/* ์ปค์คํ
์ฒดํฌ๋ฐ์ค UI */}
<div className="
flex h-5 w-5 items-center justify-center rounded border-2 border-gray-300
transition-colors
peer-checked:border-blue-600 peer-checked:bg-blue-600
">
{/* ์ฒดํฌ ํ์: peer-checked ์ ๋ณด์ */}
<svg className="hidden h-3 w-3 text-white peer-checked:block" ...>โ</svg>
</div>
{/* ๋ผ๋ฒจ ํ
์คํธ: peer-checked ์ ์์ ๋ณ๊ฒฝ */}
<span className="text-sm text-gray-700 peer-checked:font-medium peer-checked:text-blue-700">
{label}
</span>
</label>
);
}์ค์ : ํผ ์ ํจ์ฑ ๊ฒ์ฌ UI
function EmailInput() {
return (
<div className="flex flex-col gap-1.5">
<label htmlFor="email" className="text-sm font-medium text-gray-700">
์ด๋ฉ์ผ
</label>
<input
id="email"
type="email"
className="
peer rounded-lg border border-gray-300 px-4 py-2.5 text-sm
placeholder:text-gray-400
focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20
invalid:[&:not(:placeholder-shown)]:border-red-400
invalid:[&:not(:placeholder-shown)]:ring-red-400/20
"
placeholder="you@example.com"
required
/>
{/* ์๋ฌ ๋ฉ์์ง: placeholder ๊ฐ ์ ๋ณด์ผ ๋(= ๊ฐ์ด ์์ ๋) + invalid ์ํ์ผ ๋๋ง */}
<p className="
hidden text-xs text-red-500
peer-invalid:[&:not(:placeholder-shown)~&]:block
">
์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค
</p>
</div>
);
}๐ป ์ค์ : ์์๋ค ์ปค๋ฎค๋ํฐ ์ธํฐ๋ํฐ๋ธ UI
์ธํฐ๋ํฐ๋ธ ํํฐ ๋ฒํผ
type FilterButtonProps = {
label: string;
active?: boolean;
onClick: () => void;
};
function FilterButton({ label, active, onClick }: FilterButtonProps) {
return (
<button
onClick={onClick}
className={`
rounded-full px-4 py-2 text-sm font-medium transition-all
${active
? 'bg-blue-600 text-white shadow-md'
: 'bg-white text-gray-600 border border-gray-200 hover:border-blue-300 hover:text-blue-600 hover:bg-blue-50'
}
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
active:scale-95
`}
>
{label}
</button>
);
}์ธํฐ๋ํฐ๋ธ ์ ๋ ฅ ํ๋ (ํฌ์ปค์ค ๊ฐ์กฐ)
function SearchInput() {
return (
<div className="relative">
{/* ๊ฒ์ ์์ด์ฝ */}
<div className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
๐
</div>
<input
type="search"
placeholder="์คํฐ๋ ๊ฒ์..."
className="
w-full rounded-xl border border-gray-200 bg-white
py-3 pl-11 pr-4 text-sm text-gray-900
placeholder:text-gray-400
transition-all
focus:border-blue-400 focus:bg-white focus:outline-none
focus:ring-4 focus:ring-blue-500/10
hover:border-gray-300
"
/>
</div>
);
}๐ง ๊ณ ๊ธ Variant ์คํํน
Variant ๋ ์ฌ๋ฌ ๊ฐ๋ฅผ ์กฐํฉํด์ "๋ ๊ตฌ์ฒด์ ์ธ ์กฐ๊ฑด" ์ ๋ง๋ค ์ ์์ด.
<!-- ๋คํฌ ๋ชจ๋ + md ์ด์ + hover ์ -->
<div class="dark:md:hover:bg-gray-700">
<!-- ํฌ์ปค์ค + disabled ๊ฐ ์๋ ๋ -->
<button class="focus:not-disabled:ring-2">
<!-- ๊ทธ๋ฃน hover + md ์ด์ -->
<span class="md:group-hover:text-blue-600">// ์ค์ : ๋ฐ์ํ + ๋คํฌ ๋ชจ๋ + hover ๋ณตํฉ ๋ฒํผ
<button className="
w-full md:w-auto
bg-blue-600 text-white
px-6 py-3 rounded-xl
font-semibold text-sm
transition-all
hover:bg-blue-700 hover:shadow-lg
active:scale-95
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
dark:bg-blue-500 dark:hover:bg-blue-400
">
์คํฐ๋ ์ฐธ์ฌํ๊ธฐ
</button>๐ ์ด๋ฒ์ ๋ฐฐ์ด ๋ด์ฉ ์ด์ ๋ฆฌ
| Variant | ํธ๋ฆฌ๊ฑฐ | ์ฃผ์ ์ฌ์ฉ ์ฌ๋ก |
|---|---|---|
hover: | ๋ง์ฐ์ค ์ค๋ฒ | ๋ฒํผ ์์ ๋ณ๊ฒฝ, ์นด๋ ๊ทธ๋ฆผ์ |
focus: | ํฌ์ปค์ค | ์ ๋ ฅ ํ๋ ๋ง ํ์ |
focus-visible: | ํค๋ณด๋ ํฌ์ปค์ค | ์ ๊ทผ์ฑ ํฌ์ปค์ค ํ์ |
active: | ํด๋ฆญ ์ค | ๋ฒํผ ๋๋ฆผ ํจ๊ณผ |
disabled: | disabled ์์ฑ | ๋นํ์ฑํ ์คํ์ผ |
checked: | ์ฒดํฌ๋ฐ์ค ์ฒดํฌ | ์ปค์คํ ์ฒดํฌ๋ฐ์ค |
first: / last: | ์ฒซ/๋ง์ง๋ง ์์ | ๋ชฉ๋ก ๊ตฌ๋ถ์ |
odd: / even: | ํ์/์ง์ ์์ | ํ ์ด๋ธ ์ค๋ฌด๋ฌ |
group-hover: | ๋ถ๋ชจ hover | ์นด๋ ๋ด ๋ฒํผ ๋ ธ์ถ |
peer-invalid: | ํ์ invalid | ํผ ์๋ฌ ๋ฉ์์ง |
๐ ๋ง๋ฌด๋ฆฌ ํด์ฆ
Q1. ์์์ด "์นด๋์ ๋ง์ฐ์ค๋ฅผ ์ฌ๋ฆฌ๋ฉด ์นด๋ ์์ ๋ฒํผ์ด ๋ณด์ด๊ณ , ์๋ ๋๋ ์จ๊ฒจ์ค์" ๋ผ๊ณ ์์ฒญํ๋ค. JS ์์ด Tailwind ๋ง์ผ๋ก ํด๊ฒฐํ๋ ๋ฐฉ๋ฒ์?
โ
์ ๋ต: ๋ถ๋ชจ ์นด๋์ group ํด๋์ค, ๋ฒํผ์ opacity-0 group-hover:opacity-100 ํด๋์ค๋ฅผ ์ ์ฉํ๋ค.
๐ก ์์ธ ํด์ค:
group์ ๋ถ๋ชจ์ ๋ฌ์์ "์ด ์์๊ฐ hover ๋์ ๋๋ฅผ ์์๋ค์ด ๊ฐ์งํ๋ค" ๋ ์ ํธ์ผ.group-hover:opacity-100์group์ด ๋ถ์ ์กฐ์์ด hover ๋ ๋ ์ ์ฉ๋ผ.opacity-0์ด ๊ธฐ๋ณธ (์จ๊น),group-hover:opacity-100์ด hover ์ (๋ํ๋จ).transition-opacity๋ ์ถ๊ฐํ๋ฉด ์์ฐ์ค๋ฝ๊ฒ ๋ํ๋๋ ์ ๋๋ฉ์ด์ ํจ๊ณผ๊น์ง ์ค ์ ์์ด.- ๐ ํต์ฌ ๊ธฐ์ต๋ฒ: "๋ถ๋ชจ์
group, ์์์group-hover:{์คํ์ผ}."
Q2. focus:outline-none ๋ง ์ฐ๋ ๊ฒ์ด ์ํํ ์ด์ ์ ์ฌ๋ฐ๋ฅธ ๋์์?
โ
์ ๋ต: ํค๋ณด๋ ํฌ์ปค์ค ์ ์๊ฐ์ ํ์๊ฐ ์ฌ๋ผ์ ธ ์ ๊ทผ์ฑ ์๋ฐ์ด ๋๋ค. focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ์ฒ๋ผ ๋์ ํฌ์ปค์ค ์คํ์ผ์ ๋ฐ๋์ ์ ๊ณตํด์ผ ํ๋ค.
๐ก ์์ธ ํด์ค:
- ๋ธ๋ผ์ฐ์ ๊ธฐ๋ณธ
outline์ ๋ง์ฐ์ค๋ก ํด๋ฆญํ ๋๋ ๋ํ๋์ ๋์์ธ์ ํด์น๋ ๊ฒฝ์ฐ๊ฐ ์์ด. ๊ทธ๋์focus:outline-none์ ์ฐ๋ ๊ฒฝ์ฐ๊ฐ ๋ง์. - ํ์ง๋ง ์ด๋ ๊ฒ ํ๋ฉด Tab ํค๋ก ์ด๋ํ๋ ํค๋ณด๋ ์ฌ์ฉ์๋ ์คํฌ๋ฆฐ ๋ฆฌ๋ ์ฌ์ฉ์๊ฐ ํ์ฌ ํฌ์ปค์ค ์์น๋ฅผ ํ์ ํ ์ ์์ด โ WCAG 2.1 ์ ๊ทผ์ฑ ๊ฐ์ด๋๋ผ์ธ ์๋ฐ.
focus-visible:์ ๋ง์ฐ์ค ํด๋ฆญ ์์๋ ์ ์ฉ ์ ๋๊ณ ํค๋ณด๋ ํฌ์ปค์ค ์์๋ง ์ ์ฉ๋ผ์ ๋ ๋ฌธ์ ๋ฅผ ๋์์ ํด๊ฒฐํด.- ๐ ํต์ฌ ๊ธฐ์ต๋ฒ: "
focus:outline-none์focus-visible:ring-*์ ์ง์ ์ด๋ค์ผ ํ๋ค."
Q3. peer ๋ฅผ ์ด์ฉํ ํผ ์ ํจ์ฑ ๊ฒ์ฌ์์ "์๋ฌ ๋ฉ์์ง๊ฐ ๋ณด์ด์ง ์๋๋ค"๋ฉด ๊ฐ์ฅ ๋จผ์ ํ์ธํด์ผ ํ ๊ฒ์?
โ
์ ๋ต: peer ํด๋์ค๋ฅผ ๊ฐ์ง input ์ด ์๋ฌ ๋ฉ์์ง๋ณด๋ค HTML ์์ ์์(์์) ์์นํด ์๋์ง ํ์ธํ๋ค.
๐ก ์์ธ ํด์ค:
peer๋ CSS ์ ์ผ๋ฐ ํ์ ์ ํ์(~)๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ๋์ํด. CSS ์~๋ ์ดํ์ ์ค๋ ํ์ ์์ ์๋ง ์ ์ฉ๋ผ. ์ฆ,peer๊ฐ ๋ถ์ ์์๊ฐ ๊ฐ์งํ๋ ์์ ์ ์ ์์ด์ผ ํด.- ์๋ชป๋ ๊ตฌ์กฐ: ์๋ฌ ๋ฉ์์ง๊ฐ input ๋ณด๋ค ์์ ์์ผ๋ฉด
peer-invalid:๊ฐ ์๋ ์ ํด. - ์ฌ๋ฐ๋ฅธ ๊ตฌ์กฐ:
<input class="peer" />โ<p class="peer-invalid:block" /> - ๐ ํต์ฌ ๊ธฐ์ต๋ฒ: "peer ๋ ์์, ๊ฐ์งํ๋ ์์๋ ๋ค์."
๐ฃ ์์ฒ ์ด์ ํด๊ทผ ์ผ๊ธฐ
group ๊ณผ peer ๋ฅผ ์ค๋ ์ฒ์ ์ ๋๋ก ์จ๋ดค๋๋ฐ, ์ด๊ฒ ์ง์ง ๋ง๋ฒ์ด๋ค. JS ๋ก isHovered ์ํ ๊ด๋ฆฌํ๊ณ ํด๋์ค ๋์ ์ผ๋ก ๋ถ์ด๊ณ ํ๋ ๊ฒ CSS ๋ ๋ฒจ์์ ๊ทธ๋ฅ ํด๊ฒฐ๋๋ค๋.
์ํธ ๋์ด "์ํ ๊ด๋ จ UI ๋ ๊ฐ๋ฅํ๋ฉด CSS ๋ก ํด๊ฒฐํ๊ณ , JS ๋ ์ค์ ๋ฐ์ดํฐ ์ํ ๊ด๋ฆฌ์ ์ง์คํด์" ๋ผ๊ณ ํ์ จ๋๋ฐ, ์ด๊ฒ ๋ฌด์จ ๋ง์ธ์ง ์ด์ ์ฒด๊ฐ๋๋ค. ์นด๋ hover ํจ๊ณผ ๊ฐ์ ๊ฑฐ JS ์์ ๋นผ๋๊น ์ฝ๋๊ฐ ํจ์ฌ ๊น๋ํด์ง๋๋ผ.
๐ก ์ค๋์ ๊ตํ: "group ๊ณผ peer ๋ CSS ์ ์กฐ๊ฑด๋ถ ๋ ผ๋ฆฌ๋ฅผ ์ฃผ์ ํ๋ ๋๊ตฌ๋ค. JS ๋ก ํด์ผ ํ ์ผ์ CSS ๋ก ๋๋ฆฌ๋ฉด, ๊ทธ๋งํผ JS ๊ฐ ๊ฐ๋ฒผ์์ง๋ค."
์ค๋ ์์ ๋์ด "ํ์ด์ง ๋ก๋ฉ์ด ๋นจ๋ผ์ง ๊ฒ ๊ฐ๋ค" ๊ณ ํ์ จ๋๋ฐ, ์๊ณ ๋ณด๋ฉด JS ๋ฒ๋ค์ด ์ค์ด๋ ํจ๊ณผ๋ ์์ ๊ฑฐ๋ค. ๋ณด๋์ฐจ๋ค! ์ง์ ๊ฐ์ ๋งฅ์ฃผ ํ ์บ ๋ง์ ์ผ์ง. ๐บ