Framer Motion์œผ๋กœ ๋ณธ Rotating Text ๋ ˆ์ด์•„์›ƒ ์ฒ˜๋ฆฌ ๋ฐฉ์‹

์ตœ๊ทผ ์ „ ์ง์žฅ์—์„œ ํ•จ๊ป˜ํ–ˆ๋˜ ๋ฉ‹์ง„ ๋™๋ฃŒ๋ถ„์ด ๋ฐœํ‘œ์ž๋กœ ์ฐธ์—ฌํ•œ ์˜คํ”ˆ์†Œ์Šค๋ฅผ ์ฃผ์ œ๋กœ ํ•œ ๋ฐ‹์—…์— ๋‹ค๋…€์™”๋‹ค. ๋ฐœํ‘œ์—์„œ ์†Œ๊ฐœ๋œ ์˜คํ”ˆ์†Œ์Šค๋“ค์„ ์‚ดํŽด๋ณด๋‹ค๊ฐ€, React Bits ์—์„œ ์ตœ๊ทผ ๋‚ด๊ฐ€ ๋””์ž์ธ ์‹œ์Šคํ…œ์—์„œ ๊ตฌํ˜„ํ–ˆ๋˜ ํ…์ŠคํŠธ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ปดํฌ๋„ŒํŠธ์™€ ๋™์ผํ•œ ๋™์ž‘์„ ํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ฐœ๊ฒฌํ–ˆ๋‹ค. ์‹œ๊ฐ์ ์œผ๋กœ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๋ณด์ด๊ธฐ ์œ„ํ•ด ๊ณ ๋ คํ•ด์•ผ ํ•  ์ ์ด ๋งŽ์•˜๋˜ ์ปดํฌ๋„ŒํŠธ์˜€๋Š”๋ฐ, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋””ํ…Œ์ผํ•œ ๋ฌธ์ œ๋“ค์„ ํ•ด๊ฒฐํ•ด์•ผ ํ–ˆ๋‹ค.

1. ๋‹จ์–ด๊ฐ€ ๋ฐ”๋€” ๋•Œ ๋ฌธ์žฅ ์ „์ฒด๊ฐ€ ํ”๋“ค๋ฆฌ๋Š” ๋ฌธ์ œ

์˜ˆ๋ฅผ ๋“ค์–ด

  • “์˜ค๋Š˜์€ ํ”ผ์ž๋ฅผ ๋จน์„๊นŒ์š”?”
  • “์˜ค๋Š˜์€ ์ƒค๋ธŒ์ƒค๋ธŒ๋ฅผ ๋จน์„๊นŒ์š”?”

๋‘ ๋ฌธ์žฅ์€ ๋™์ ์œผ๋กœ ๊ต์ฒด๋˜๋Š” ๋‹จ์–ด(๋ณผ๋“œ๋กœ ํ‘œ์‹œ๋œ ๋ถ€๋ถ„)๋งŒ ๋‹ค๋ฅด์ง€๋งŒ, ์ด ๋‹จ์–ด์˜ ํญ์ด ๋‹ฌ๋ผ์ง€๋Š” ์ˆœ๊ฐ„ ๋’ค์ชฝ ํ…์ŠคํŠธ๊ฐ€ ์ขŒ์šฐ๋กœ ํ”๋“ค๋ ค ๋ณด์ธ๋‹ค.

์›์ธ

ํ…์ŠคํŠธ๊ฐ€ ๋ฐ”๋€Œ๋ฉด ํ•ด๋‹น span์˜ width๊ฐ€ ์ฆ‰์‹œ ์žฌ๊ณ„์‚ฐ๋˜๋ฉด์„œ ์˜ค๋ฅธ์ชฝ ํ…์ŠคํŠธ๊ฐ€ ๋ฐ€๋ฆฌ๊ฑฐ๋‚˜ ๋‹น๊ฒจ์ง„๋‹ค.

ํ•ด๊ฒฐ

๋ชจ๋“  ๋‹จ์–ด๋ฅผ ๋ Œ๋”๋ง ์ „์— ์ž„์‹œ span์œผ๋กœ ์ธก์ •ํ•˜์—ฌ ๊ฐ€์žฅ ๊ธด ๋‹จ์–ด์˜ ํญ์œผ๋กœ ์ปจํ…Œ์ด๋„ˆ width๋ฅผ ๊ณ ์ •ํ•ด์„œ ํ•ด๊ฒฐํ•˜์˜€๋‹ค.

  • visibility: hidden, position: absolute๋กœ ๊ณต๊ฐ„ ์ฐจ์ง€ ์—†์ด ์ž„์‹œ ์ธก์ •
  • getBoundingClientRect()๋กœ ์‹ค์ œ ํญ ๊ณ„์‚ฐ
  • ๊ฐ€์žฅ ๊ธด ํญ์œผ๋กœ ๊ณ ์ •ํ•˜๋ฉด ํ”๋“ค๋ฆผ์ด ์‚ฌ๋ผ์ง„๋‹ค

2. ์˜ค๋ฅธ์ชฝ ์ •๋ ฌ์ผ ๋•Œ ๋‹จ์–ด๊ฐ€ ๋“ค์‘ฅ๋‚ ์‘ฅํ•ด ๋ณด์ด๋Š” ๋ฌธ์ œ

๋ฌธ์žฅ์ด ์•„๋ž˜์™€ ๊ฐ™์€ ํ˜•ํƒœ์ผ ๋•Œ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋Š”๋ฐ, ๋™์  ๋‹จ์–ด๊ฐ€ ๊ณ ์ • ๋ฌธ์ž ๊ธฐ์ค€์œผ๋กœ ์™ผ์ชฝ์— ์žˆ๋Š” ๊ฒฝ์šฐ์— ์งง์œผ๋ฉด ์™ผ์ชฝ์— ๋ถ™๊ณ , ๊ธธ๋ฉด ์ค‘์•™์œผ๋กœ ์˜ค๊ธฐ ๋•Œ๋ฌธ์— ์˜ค๋ฅธ์ชฝ ํ…์ŠคํŠธ์™€์˜ ๊ฐ„๊ฒฉ์ด ๊ณ„์† ์ผ์ •ํ•˜์ง€ ์•Š๊ฒŒ ๋ณด์˜€๋‹ค.

[๋™์  ๋‹จ์–ด] [์˜ค๋ฅธ์ชฝ prefix]

ํ•ด๊ฒฐ

๋™์  ๋‹จ์–ด๊ฐ€ ํ•ญ์ƒ ๋™์ผํ•œ ๊ธฐ์ค€์ ์—์„œ ๋ Œ๋”๋ง ๋˜๋„๋ก ์ตœ์žฅ ๋‹จ์–ด ๊ธธ์ด๋ฅผ ๊ธฐ์ค€์œผ๋กœ, ๋ถ€์กฑํ•œ ๊ธธ์ด๋งŒํผ ์•ž์ชฝ์— ํˆฌ๋ช…ํ•œ ํŒจ๋”ฉ์„ ์กฐ๊ฑด๋ถ€๋กœ ์‚ฝ์ž…ํ•˜๋Š” ๋ฐฉ์‹์„ ์‚ฌ์šฉํ–ˆ๋‹ค.

3. ์ดˆ๊ธฐ ๋ Œ๋”๋ง ์‹œ ๋ ˆ์ด์•„์›ƒ์ด ๊นจ์ ธ ๋ณด์ด๋Š” ๋ฌธ์ œ

์ฒ˜์Œ ๋กœ๋“œ๋˜๋Š” ์ˆœ๊ฐ„, width ๊ณ„์‚ฐ์ด ๋˜๊ธฐ ์ „์˜ ์ƒํƒœ๊ฐ€ ์ž ๊น ๋ณด์ด๋ฉด์„œ ๋ฌธ์žฅ์ด ์–ด๊ธ‹๋‚˜ ๋ณด์˜€๋‹ค.

์›์ธ

useEffect๋Š” ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ํ™”๋ฉด์„ ๊ทธ๋ฆฐ ๋’ค ์‹คํ–‰๋˜๋ฏ€๋กœ, “๊นจ์ง„ ํ™”๋ฉด → ๊ณ„์‚ฐ → ๊ณ ์ •” ์ˆœ์„œ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.

ํ•ด๊ฒฐ

width ๊ณ„์‚ฐ๊ณผ ์ดˆ๊ธฐ ์Šคํƒ€์ผ ์ ์šฉ์„ ๋ชจ๋‘ useLayoutEffect๋กœ ๋ณ€๊ฒฝํ–ˆ๋‹ค. useLayoutEffect๋Š” DOM ์—…๋ฐ์ดํŠธ ํ›„, ํ™”๋ฉด์„ ๊ทธ๋ฆฌ๊ธฐ ์ „์— ๋™๊ธฐ์ ์œผ๋กœ ์‹คํ–‰๋˜๋ฏ€๋กœ ๊นจ์ง€๋Š” ์ด์Šˆ๊ฐ€ ํ•ด๊ฒฐ๋˜์—ˆ๋‹ค.

โœจ React Bits์˜ Rotating Text

RotatingTextDemo ์ฝ”๋“œ ์‚ดํŽด๋ณด๊ธฐ

RotatingText ์ฝ”๋“œ ์‚ดํŽด๋ณด๊ธฐ

<LayoutGroup>
  <motion.p className="rotating-text-ptag" layout>
    <motion.span
      className="pt-0.5 sm:pt-1 md:pt-2"
      layout
      transition={{ type: 'spring', damping: 30, stiffness: 400 }}
    >
      Creative{' '}
    </motion.span>
    <RotatingText
      texts={words}
      mainClassName="rotating-text-main"
      staggerFrom={'last'}
      initial={{ y: '100%' }}
      animate={{ y: 0 }}
      exit={{ y: '-120%' }}
      staggerDuration={0.025}
      splitLevelClassName="rotating-text-split"
      transition={{ type: 'spring', damping: 30, stiffness: 400 }}
      rotationInterval={2000}
    />
  </motion.p>
</LayoutGroup>;

React Bits์˜ Rotating Text ์ฝ”๋“œ๋ฅผ ์‹ค์ œ๋กœ ์‚ดํŽด๋ณด๋‹ˆ, ๊ตฌ์กฐ์ ์œผ๋กœ ๋ˆˆ์— ๋„๋Š” ๋ถ€๋ถ„๋“ค์ด ๋ช‡ ๊ฐ€์ง€ ์žˆ์—ˆ๋‹ค.

1. ์ปดํฌ๋„ŒํŠธ ์ „์ฒด๊ฐ€ Framer Motion์˜ LayoutGroup์œผ๋กœ ๊ฐ์‹ธ์ ธ ์žˆ์—ˆ๋‹ค. ๊ณ ์ • ํ…์ŠคํŠธ์™€ ๋™์  ํ…์ŠคํŠธ๋ฅผ ํ•˜๋‚˜์˜ ๋ ˆ์ด์•„์›ƒ ๊ทธ๋ฃน ์•ˆ์—์„œ ๊ด€๋ฆฌํ•ด, ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ถ€์—์„œ ๋ฐœ์ƒํ•˜๋Š” ๋ ˆ์ด์•„์›ƒ ๋ณ€ํ™”๋ฅผ Motion์ด ์ผ๊ด€๋˜๊ฒŒ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋„๋ก ์„ค๊ณ„๋˜์–ด ์žˆ์—ˆ๋‹ค.

2. ์ •์  ํ…์ŠคํŠธ ์—ญ์‹œ motion.* ์ปดํฌ๋„ŒํŠธ๋กœ ๊ฐ์‹ธ๊ณ  layout ์†์„ฑ์„ ์ ์šฉํ•˜๊ณ  ์žˆ์—ˆ๋‹ค. <motion.playout>์ด๋‚˜ <motion.span layout>์ฒ˜๋Ÿผ ๊ณ ์ •๋œ ์š”์†Œ๊นŒ์ง€ ๋ ˆ์ด์•„์›ƒ ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋Œ€์ƒ์— ํฌํ•จ์‹œํ‚ค๋Š” ๋ฐฉ์‹์ด์—ˆ๋‹ค.

3. RotatingText ์ž์ฒด๋„ Motion ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌํ˜„๋˜์–ด ์žˆ์—ˆ๋‹ค. ๋‹จ์–ด์˜ ๋“ฑ์žฅ/ํ‡ด์žฅ ์• ๋‹ˆ๋ฉ”์ด์…˜์€ initial, animate, exit๋กœ ์ฒ˜๋ฆฌํ•˜๊ณ ,
๋ ˆ์ด์•„์›ƒ ๋ณ€ํ™”๋Š” layout๊ณผ ์—์„œ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ตฌ์กฐ์˜€๋‹ค.

์ •๋ฆฌํ•˜๋ฉด, ๋‚ด๊ฐ€ ์ง์ ‘ ํญ ์ธก์ •์ด๋‚˜ baseline ์ •๋ ฌ, ์ดˆ๊ธฐ ๊นœ๋นก์ž„ ๋ฐฉ์ง€ ๋“ฑ์„ ์ˆ˜๋™์œผ๋กœ ์ฒ˜๋ฆฌํ–ˆ๋˜ ๋ถ€๋ถ„๋“ค์„ React Bits๋Š” Framer Motion์˜ ๋ ˆ์ด์•„์›ƒ ์‹œ์Šคํ…œ์— ์œ„์ž„ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ํ•ด๊ฒฐํ•˜๊ณ  ์žˆ์—ˆ๋‹ค.

โœจ Framer Motion - FLIP ๊ธฐ๋ฒ•

Framer Motion ๋‚ด๋ถ€ ๋™์ž‘์„ ๋” ์‚ดํŽด๋ณด๋ฉด, ๋ ˆ์ด์•„์›ƒ ์ „ํ™˜์€ FLIP(First, Last, Invert, Play) ๊ธฐ๋ฒ•์œผ๋กœ ์ฒ˜๋ฆฌ๋˜๊ณ  ์žˆ๋‹ค.
๋‚˜๋Š” ์ด๋ฒˆ์— ์ฒ˜์Œ ์•Œ๊ฒŒ ๋œ ๊ธฐ๋ฒ•์ด๋ผ ๊ฐ„๋‹จํžˆ ์ •๋ฆฌํ•ด๋ณด๋ฉด, ๋ ˆ์ด์•„์›ƒ์ด ๋ฐ”๋€Œ๋Š” ์ƒํ™ฉ์—์„œ ํŠ€๋Š” ๋А๋‚Œ ์—†์ด ๋ถ€๋“œ๋Ÿฌ์šด ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ๋งŒ๋“œ๋Š” ๋ฐ ์ตœ์ ํ™”๋œ ๋ฐฉ์‹์ด๋ผ๊ณ  ํ•œ๋‹ค. ํ•ต์‹ฌ ์•„์ด๋””์–ด๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

  • ์‹ค์ œ ๋ ˆ์ด์•„์›ƒ์€ ๋ฐ”๋กœ ๋ฐ˜์˜ํ•œ๋‹ค.
  • ์ดํ›„, transform์œผ๋กœ ์ด์ „ ์œ„์น˜์— ์žˆ์—ˆ๋˜ ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ด๋„๋ก ๋ณด์ •ํ•œ๋‹ค.
  • ๋งˆ์ง€๋ง‰์œผ๋กœ ๊ทธ transform ๊ฐ’์„ 0๊นŒ์ง€ ์• ๋‹ˆ๋ฉ”์ด์…˜ํ•˜๋ฉฐ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ƒˆ ๋ ˆ์ด์•„์›ƒ์— ์•ˆ์ฐฉ์‹œํ‚จ๋‹ค.

1. F — First

์š”์†Œ๊ฐ€ ๋ณ€๊ฒฝ๋˜๊ธฐ ์ „์˜ ์œ„์น˜์™€ ํฌ๊ธฐ๋ฅผ ์ธก์ •ํ•œ๋‹ค. Motion ๋‚ด๋ถ€์—์„œ๋Š” willUpdate() ์‹œ์ ์—์„œ snapshot์„ ์ €์žฅํ•œ๋‹ค.
AnimatedText์—์„œ ์ƒ๊ฐํ•œ๋‹ค๋ฉด,

  • “ํ”ผ์ž” ์œ„์น˜์™€ ํญ
  • “ํ–„๋ฒ„๊ฑฐ”๋กœ ๋ณ€๊ฒฝ๋˜๊ธฐ ์ง์ „ ์ƒํƒœ
    ์ด ์‹œ์ ์˜ ๋ ˆ์ด์•„์›ƒ์ด First์— ํ•ด๋‹นํ•œ๋‹ค.
// packages/framer-motion/src/projection/node/create-projection-node.ts

willUpdate(shouldNotifyListeners = true) {
    // ... ์ƒ๋žต ...

    if (this.instance) {
        // ํ˜„์žฌ ์œ„์น˜์™€ ํฌ๊ธฐ๋ฅผ ์ธก์ •ํ•˜์—ฌ snapshot์— ์ €์žฅ
        this.snapshot = this.measure()
    }

    this.updateSnapshot()
    shouldNotifyListeners && this.notifyListeners("willUpdate")
}

updateSnapshot() {
    if (this.snapshot || !this.instance) return

    // ํ˜„์žฌ ๋ ˆ์ด์•„์›ƒ ์Šค๋ƒ…์ƒท ์ €์žฅ
    this.snapshot = this.measure()
}

2. L — Last

React ๋ Œ๋”๋ง์ด ๋๋‚œ ๋’ค, ๋ณ€๊ฒฝ๋œ ์ดํ›„์˜ ์œ„์น˜์™€ ํฌ๊ธฐ๋ฅผ ๋‹ค์‹œ ์ธก์ •ํ•˜๋Š”๋ฐ, Motion์—์„œ๋Š” updateLayout() ๋‹จ๊ณ„์—์„œ ์ธก์ •ํ•œ๋‹ค.

  • “ํ–„๋ฒ„๊ฑฐ”๊ฐ€ ๋ Œ๋”๋ง ๋œ ์ดํ›„์˜ ํญ
  • ์ฃผ๋ณ€ ํ…์ŠคํŠธ๊ฐ€ ๋ฐ€๋ ค๋‚œ ๊ฒฐ๊ณผ ๋ ˆ์ด์•„์›ƒ
// packages/framer-motion/src/projection/node/create-projection-node.ts

updateLayout() {
    if (!this.instance) return

    // ... ์ƒ๋žต ...

    const prevLayout = this.layout

    // ์ƒˆ๋กœ์šด ๋ ˆ์ด์•„์›ƒ ์ธก์ • (React ๋ฆฌ๋ Œ๋”๋ง ํ›„)
    this.layout = this.measure(false)
    this.layoutCorrected = createBox()
    this.isLayoutDirty = false

    this.notifyListeners("measure", this.layout.layoutBox)
}

3. I — Invert

First์™€ Last ์‚ฌ์ด์˜ ์ฐจ์ด๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ delta๋ฅผ ๊ณ„์‚ฐํ•˜๋Š”๋ฐ, ์ด delta๋ฅผ scale/translate transform์œผ๋กœ ์—ญ์ ์šฉํ•ด, ์š”์†Œ๊ฐ€ "์˜›๋‚  ์œ„์น˜์— ์žˆ๋Š” ๊ฒƒ์ฒ˜๋Ÿผ" ๋ณด์ด๊ฒŒ ๋งŒ๋“ ๋‹ค. ์ฆ‰ ์‹ค์ œ DOM์€ ์ด๋ฏธ ๋ณ€ํ™”๋œ ์ƒํƒœ์ง€๋งŒ, transform์ด ๊ทธ ์ฐจ์ด๋ฅผ ๋ณด์ •ํ•ด ์ค€๋‹ค. ์ด ๋‹จ๊ณ„์—์„œ ๋ ˆ์ด์•„์›ƒ ์ ํ”„๊ฐ€ ์‚ฌ๋ผ์ง„๋‹ค. ๋‚ด๊ฐ€ ๊ตฌํ˜„ํ–ˆ๋˜ AnimatedText์—์„œ๋Š” ์ง์ ‘ width๋ฅผ ๊ณ„์‚ฐํ•ด ๊ณ ์ •ํ•ด ์คฌ์ง€๋งŒ, FLIP ๋ฐฉ์‹์€ ์ด๋Ÿฌํ•œ ๊ณ„์‚ฐ ์ž์ฒด๋ฅผ ๋ถˆํ•„์š”ํ•˜๊ฒŒ ๋งŒ๋“ ๋‹ค.

// packages/framer-motion/src/projection/geometry/delta-calc.ts

export function calcAxisDelta(
    delta: AxisDelta,
    source: Axis,      // First (์ด์ „ ์ƒํƒœ)
    target: Axis,      // Last (์ƒˆ๋กœ์šด ์ƒํƒœ)
    origin: number = 0.5
) {
    delta.origin = origin
    delta.originPoint = mixNumber(source.min, source.max, delta.origin)

    // ์ด์ „ ํฌ๊ธฐ๋ฅผ ์ƒˆ ํฌ๊ธฐ๋กœ ๋งŒ๋“ค๊ธฐ ์œ„ํ•œ scale ๊ณ„์‚ฐ
    delta.scale = calcLength(target) / calcLength(source)

    // ์œ„์น˜ ์ด๋™ ๊ณ„์‚ฐ
    delta.translate =
        mixNumber(target.min, target.max, delta.origin) - delta.originPoint

    // ๋ฏธ์„ธํ•œ ์ฐจ์ด๋Š” ๋ฌด์‹œํ•œ๋‹ค!
    if ((delta.scale >= 0.9999 && delta.scale <= 1.0001) || isNaN(delta.scale)) {
        delta.scale = 1.0
    }
}

export function calcBoxDelta(
    delta: Delta,
    source: Box,  // snapshot (First)
    target: Box,  // layout (Last)
    origin?: ResolvedValues
): void {
    calcAxisDelta(delta.x, source.x, target.x, origin?.originX)
    calcAxisDelta(delta.y, source.y, target.y, origin?.originY)
}

4. P — Play

๋งˆ์ง€๋ง‰์œผ๋กœ delta์„ scale/translate 0๊นŒ์ง€ ์• ๋‹ˆ๋ฉ”์ด์…˜ ํ•˜๋ฉฐ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ƒˆ๋กœ์šด ๋ ˆ์ด์•„์›ƒ์œผ๋กœ ์ด๋™ํ•œ๋‹ค.
์•„๋ž˜๋Š” ๊ฐ„๋‹จํ•œ ์˜ˆ์‹œ๋‹ค. scale์ด ๋ณ€ํ™”ํ•˜๋ฉด์„œ ์–ด๋–ป๊ฒŒ ํญ์ด ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๋ณด์ •๋˜๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

// progress = 0.0 (์‹œ์ž‘)
scaleX = 0.5 * (1 - 0.0) + 1.0 * 0.0 = 0.5  // 40px์ฒ˜๋Ÿผ ๋ณด์ž„

// progress = 0.5 (์ค‘๊ฐ„)
scaleX = 0.5 * (1 - 0.5) + 1.0 * 0.5 = 0.75 // 60px

// progress = 1.0 (์™„๋ฃŒ)
scaleX = 0.5 * (1 - 1.0) + 1.0 * 1.0 = 1.0  // 80px (์›๋ž˜ ํฌ๊ธฐ)

transform ๊ธฐ๋ฐ˜์ด๊ธฐ ๋•Œ๋ฌธ์—

  • GPU ๊ฐ€์†
  • ๋ฆฌํ”Œ๋กœ์šฐ ์ƒ๋žต
  • 60fps ์œ ์ง€
  • ์ฃผ๋ณ€ ๋ ˆ์ด์•„์›ƒ ์˜ํ–ฅ ์—†์Œ

์ด ๋ชจ๋“  ์กฐ๊ฑด์„ ๋งŒ์กฑํ•˜๋ฉฐ ์ž์—ฐ์Šค๋Ÿฌ์šด ๋ ˆ์ด์•„์›ƒ ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ๊ฐ€๋Šฅํ•ด์กŒ๋˜ ๊ฒƒ์ด์—ˆ๋‹ค.

//๋งค ํ”„๋ ˆ์ž„ ๋ฆฌํ”Œ๋กœ์šฐ
element.style.width = '40px';  // Reflow
animate(() => {
  element.style.width = `${currentWidth}px`;  // ๋งค ํ”„๋ ˆ์ž„ Reflow
});

// FLIP ๋ฐฉ์‹ (๋‹จ 1ํšŒ ๋ฆฌํ”Œ๋กœ์šฐ)
element.style.width = '80px';           // Reflow 1ํšŒ
element.style.transform = 'scaleX(0.5)'; // Composite only
animate(() => {
  element.style.transform = `scaleX(${scale})`;  // Composite only
});

๋‹ค๋งŒ, FLIP ๊ธฐ๋ฒ•๋งŒ์œผ๋กœ๋Š” Rotating Text๋ฅผ ์™„์ „ํžˆ ๊ตฌํ˜„ํ•˜๊ธฐ ์–ด๋ ต๋‹ค. FLIP์€ ๊ธฐ๋ณธ์ ์œผ๋กœ “๊ฐœ๋ณ„ ์š”์†Œ๊ฐ€ ์ž์‹ ์˜ ๋ ˆ์ด์•„์›ƒ ๋ณ€ํ™”์— ๋งž์ถฐ ๋ถ€๋“œ๋Ÿฝ๊ฒŒ ์ „ํ™˜๋˜๋„๋ก” ๋งŒ๋“œ๋Š” ๊ธฐ๋ฒ•์ด๊ธฐ ๋•Œ๋ฌธ์—, Rotating Text์ฒ˜๋Ÿผ ์—ฌ๋Ÿฌ ์š”์†Œ๊ฐ€ ๋™์‹œ์— ์œ„์น˜๊ฐ€ ๋ณ€ํ•˜๊ณ , ๋™์  ๋‹จ์–ด์˜ ๊ธธ์ด ๋ณ€ํ™”๊ฐ€ ์–‘์ชฝ์˜ ์ •์  ํ…์ŠคํŠธ๊นŒ์ง€ ํ•จ๊ป˜ ์ด๋™์‹œํ‚ค๋Š” ๊ตฌ์กฐ์—์„œ๋Š” ์š”์†Œ ํ•˜๋‚˜๋งŒ ์ž˜ ์›€์ง์ธ๋‹ค๊ณ  ํ•ด๊ฒฐ๋˜์ง€ ์•Š๋Š”๋‹ค. ์ด ๊ฒฝ์šฐ์—๋Š” ์—ฌ๋Ÿฌ ์š”์†Œ์˜ ๋ ˆ์ด์•„์›ƒ ๋ณ€ํ™”๋ฅผ ํ•˜๋‚˜์˜ ๋‹จ์œ„๋กœ ๋ฌถ์–ด์ฃผ๋Š” ์ƒ์œ„ ๊ฐœ๋…์ด ํ•„์š”ํ•œ๋ฐ, React Bits์˜ RotatingText ๋‚ด๋ถ€ ๊ตฌํ˜„์„ ๋ณด๋ฉด, ์ด๋ฅผ ์œ„ํ•ด Framer Motion์˜ LayoutGroup์œผ๋กœ ์ „์ฒด ๋ฌธ์žฅ์„ ๊ฐ์‹ธ๊ณ  ์žˆ๋‹ค.

โœจ Framer Motion - LayoutGroup

LayoutGroup์˜ ์—ญํ• ์„ ํ•œ ๋ฌธ์žฅ์œผ๋กœ ์ •๋ฆฌํ•˜๋ฉด, ๊ฐ™์€ ๊ทธ๋ฃน ์•ˆ์˜ ๋ชจ๋“  motion ์š”์†Œ๊ฐ€ ๋™์ผํ•œ ์ˆœ๊ฐ„์— FLIP ์‚ฌ์ดํด์„ ์‹œ์ž‘ํ•˜๋„๋ก ์กฐ์œจํ•˜๋Š” ๊ฒƒ์ด๋‹ค. ๋•๋ถ„์— Rotating Text์ฒ˜๋Ÿผ ์—ฌ๋Ÿฌ ์š”์†Œ๊ฐ€ ํ•œ ๋ฒˆ์— ์›€์ง์ด๋Š” UI์—์„œ๋„ ๊ฐ๊ฐ์ด ์ œ๊ฐ๊ฐ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์‹œ์ž‘ํ•˜์ง€ ์•Š๊ณ , ๋ฌธ์žฅ ์ „์ฒด๊ฐ€ ํ•˜๋‚˜์˜ ๋‹จ์œ„์ฒ˜๋Ÿผ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ „ํ™˜๋œ๋‹ค. ์‹ค์ œ ๋‚ด๋ถ€ ์ฝ”๋“œ๋ฅผ ์‚ดํŽด๋ณด๋ฉด ์•ˆ์˜ ๋ชจ๋“  motion ์š”์†Œ๋ฅผ Set์œผ๋กœ ๋ชจ์•„๋‘๊ณ  ์–ด๋А ํ•œ ์š”์†Œ์—์„œ๋“  willUpdate๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด, ๊ทธ๋ฃน ์ „์ฒด์— dirtyAll()์„ ํ˜ธ์ถœํ•ด ํ˜•์ œ ์š”์†Œ ๋ชจ๋‘๊ฐ€ FLIP์˜ First ๋‹จ๊ณ„๋ฅผ ๋™์‹œ์— ์‹œ์ž‘ํ•˜๋„๋ก ๋งŒ๋“ ๋‹ค.

// packages/framer-motion/src/projection/node/group.ts

export function nodeGroup(): NodeGroup {
  const nodes = new Set<IProjectionNode>()

  const dirtyAll = () => nodes.forEach(notify)
  
  
  // ์–ด๋–ค ๋…ธ๋“œ์—์„œ๋“  willUpdate๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด
  // ๊ฐ™์€ ๊ทธ๋ฃน์˜ ๋ชจ๋“  ๋…ธ๋“œ์—๊ฒŒ dirtyAll์„ ํ˜ธ์ถœํ•œ๋‹ค
  return {
    add: (node) => {
      nodes.add(node)
      node.addEventListener("willUpdate", dirtyAll)
    },
    remove: (node) => {
      nodes.delete(node)
      dirtyAll()
    },
    dirty: dirtyAll,
  }
}

const notify = (node: IProjectionNode) =>
  !node.isLayoutDirty && node.willUpdate(false)

 

๋А๋‚€์ 

์ด๋ฒˆ ์˜คํ”ˆ์†Œ์Šค ๋ถ„์„์„ ํ†ตํ•ด, ์—ฌ๋Ÿฌ ์š”์†Œ๊ฐ€ ๋™์‹œ์— ์›€์ง์ด๋Š” UI์—์„œ๋Š” ๊ฐœ๋ณ„ span์— ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์ ์šฉํ•˜๊ธฐ๋ณด๋‹ค LayoutGroup์ฒ˜๋Ÿผ ๊ทธ๋ฃน ๋‹จ์œ„๋กœ ๋ ˆ์ด์•„์›ƒ์„ ๋ฌถ์–ด ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฐฉ์‹์ด ํ›จ์”ฌ ์ž์—ฐ์Šค๋Ÿฝ๋‹ค๋Š” ์ ์„ ๋ฐฐ์› ๋‹ค. ์ด๋ฒˆ์— ์ฒ˜์Œ ์•Œ๊ฒŒ ๋œ FLIP ๊ธฐ๋ฒ•๋„ ํฅ๋ฏธ๋กœ์› ๋‹ค. Q&A์—์„œ ‘์˜คํ”ˆ์†Œ์Šค์—์„œ ๋ฐฐ์šด ๊ฒƒ์„ ์–ด๋–ป๊ฒŒ ํ™œ์šฉํ•˜๋А๋ƒ’๋Š” ์งˆ๋ฌธ์ด ๋‚˜์™”๋Š”๋ฐ, ๋ฐœํ‘œ์ž๋ถ„์€ ๋‹น์žฅ ์“ฐ์ด์ง€ ์•Š์•„๋„ ์–ธ์  ๊ฐ€๋Š” ‘์–ด, ๊ทธ๊ฒŒ ์žˆ์—ˆ์ง€’ ํ•˜๊ณ  ๋– ์˜ฌ๋ผ ๋„์›€์ด ๋œ๋‹ค๊ณ  ๋‹ตํ–ˆ๋‹ค. ๋‚˜ ์—ญ์‹œ ์ด๋ฒˆ ๋ฐฐ์›€์ด ๊ทธ๋Ÿฐ ๊ฒฝํ—˜์œผ๋กœ ๋‚จ๊ธฐ๋ฅผ ๊ธฐ๋Œ€ํ•œ๋‹ค.