Docs
Text
Letter 3D Swap

Letter 3D Swap

A text component that swap the letters in a text with a box 3D effect.

SET YOUR MIND TO IT

S
S
E
E
T
T
Y
Y
O
O
U
U
R
R
M
M
I
I
N
N
D
D
T
T
O
O
I
I
T
T

Installation


1pnpm dlx shadcn@latest add "https://fancycomponents.dev/r/letter-3d-swap.json"

Usage


Just wrap your text with the component and set the rotateDirection prop to the direction you want the text to rotate, the rest will be taken care by the component.

Understanding the component


Splitting the text into characters

First, we split the text into WorldObject objects, each containing an array of characters and a boolean indicating whether there should be a space after the character. We use a handy function for this, which should respect emojis too.

Splitting the text into characters

1// handy function to split text into characters with support for unicode and emojis
2const splitIntoCharacters = (text: string): string[] => {
3 if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
4 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
5 return Array.from(segmenter.segment(text), ({ segment }) => segment)
6 }
7 // Fallback for browsers that don't support Intl.Segmenter
8 return Array.from(text)
9}

This method also helps us to ensure that words stay together and properly spaced when the text wraps across multiple lines. Without this approach, simply splitting by characters would break words at line boundaries.

Splitting the text into animation segments

1// Splitting the text into animation segments
2const characters = useMemo(() => {
3 const t = text.split(" ")
4 const result = t.map((word: string, i: number) => ({
5 characters: splitIntoCharacters(word),
6 needsSpace: i !== t.length - 1,
7 }))
8 return result
9}, [text])

3D Transforms

When rendering each character, we create two instances of it - a front face and a second face. The second face is positioned relative to the first one and uses 3D CSS transforms to create the illusion that it's on a different face of a 3D box. The face it appears on depends on the rotateDirection prop:

  • "top" - Character appears to flip upward from the top face
  • "right" - Character appears to flip from the right side
  • "bottom" - Character appears to flip downward from the bottom face
  • "left" - Character appears to flip from the left side

Top and bottom rotations

For top and bottom rotations, we create a 3D box effect through a series of transforms:

  1. The front face is brought forward by translating it 0.5lh on the Z axis (lh represents one line height)
  2. For the second face, we:
    • Rotate it 90° (or -90°) on the X axis
    • Then translate it 0.5lh forward in its local coordinate system to align with the edge of our virtual box
  3. Finally, we translate the container back by -0.5lh to account for the initial translation of the front face

This creates the illusion of characters flipping between two faces of a 3D cube. The demo below shows how these transforms work together:

A
A

Left and right rotations

For left/right rotations, we need to handle the box dimensions more carefully. Unlike top/bottom rotations where we can use line height (lh) as a fixed measurement, the width of each character varies. The side faces of our 3D box need to match the actual character width.

To achieve this, we use percentage-based translations on the X and Y axes, since these can automatically adapt to each character's width. The transform sequence works like this:

  1. First face:

    • Rotate 90° on Y axis to face sideways
    • Translate 50% of character width to align with edge
    • Rotate -90° on Y axis to face forward again
  2. Second face:

    • Apply the same transforms as the first face
    • Add additional transforms to position it correctly on the side
  3. Lastly, we push back both faces to account for the initial translation

The demo below shows this transform sequence step by step:

A
A

Why the initial translation?

The initial forward translation of our box (using 0.5lh for top/bottom rotations, or the transform chain for left/right rotations) serves an important purpose. It ensures the rotation axis passes through the center of our virtual 3D box, rather than along its front face. This creates a more natural flipping motion, as the character rotates around its center point rather than pivoting from its front edge. Without this translation, the box rotation would appear to swing outward in an unnatural arc rather than flipping in place.

Of course, you can achieve the same result by applying (other) transforms in a different order, and even playing with the transform origins. I apologise if this seems overcomplicated, this is how it made sense to me :).

Animation

Now that we have our virtual 3D box, the only thing left is to rotate each character box. For this, we use the useAnimate hook from motion. This gives us a scope and an animate function to control the animation. We add .letter-3d-swap-char-box-item class name to each char box, so we can select and animate them with the animate function. After the animation is completed, we reset the transform to the original state.

Animation

1// Animate each character with its specific delay
2await animate(
3 ".letter-3d-swap-char-box-item",
4 { transform: rotationTransform },
5 {
6 ...transition,
7 delay: (i: number) => delays[i],
8 }
9
10// Reset all boxes
11await animate(
12 ".letter-3d-swap-char-box-item",
13 { transform: "rotateX(0deg) rotateY(0deg)" },
14 { duration: 0 }
15)

The transform is just a 90/-90 degree rotation either on the X or Y axis, depending on the rotateDirection prop.

Stagger

The delay is calculated based on the staggerFrom prop, which can be set to first, last, center, random or a number. If it's a number, it's used as the index of the character to stagger from. For example, if staggerFrom is set to 2, the second character will be staggered from the third one. We have a handy function to calculate the correct delay for each character:

Stagger delay calculation

1// Helper function to calculate stagger delay for each text segment
2const getStaggerDelay = useCallback(
3 (index: number, totalChars: number) => {
4 const total = totalChars
5 if (staggerFrom === "first") return index * staggerDuration
6 if (staggerFrom === "last") return (total - 1 - index) * staggerDuration
7 if (staggerFrom === "center") {
8 const center = Math.floor(total / 2)
9 return Math.abs(center - index) * staggerDuration
10 }
11 if (staggerFrom === "random") {
12 const randomIndex = Math.floor(Math.random() * total)
13 return Math.abs(randomIndex - index) * staggerDuration
14 }
15 return Math.abs(staggerFrom - index) * staggerDuration
16 },
17 [staggerFrom, staggerDuration]
18)

Check out the demo to see the possible values for staggerFrom.

Rotate Left, Stagger First

R
R
o
o
t
t
a
a
t
t
e
e
L
L
e
e
f
f
t
t
,
,
S
S
t
t
a
a
g
g
g
g
e
e
r
r
F
F
i
i
r
r
s
s
t
t

Rotate Right, Stagger Last

R
R
o
o
t
t
a
a
t
t
e
e
R
R
i
i
g
g
h
h
t
t
,
,
S
S
t
t
a
a
g
g
g
g
e
e
r
r
L
L
a
a
s
s
t
t

Rotate Top, Stagger Center

R
R
o
o
t
t
a
a
t
t
e
e
T
T
o
o
p
p
,
,
S
S
t
t
a
a
g
g
g
g
e
e
r
r
C
C
e
e
n
n
t
t
e
e
r
r

Rotate Bottom, Stagger Random

R
R
o
o
t
t
a
a
t
t
e
e
B
B
o
o
t
t
t
t
o
o
m
m
,
,
S
S
t
t
a
a
g
g
g
g
e
e
r
r
R
R
a
a
n
n
d
d
o
o
m
m

Resources


Props


Letter3DSwapProps

PropTypeDefaultDescription
children*React.ReactNode-The content to be displayed and animated
asElementTypepHTML Tag to render the component as
mainClassNamestring-Class name for the main container element
frontFaceClassNamestring-Class name for the front face element
secondFaceClassNamestring-Class name for the secondary face element
staggerDurationnumber0.05Duration of stagger delay between elements in seconds
staggerFrom"first" | "last" | "center" | "random" | number"first"Direction to stagger animations from
transitionValueAnimationTransition | AnimationOptions{ type: "spring", damping: 25, stiffness: 300 }Animation transition configuration
rotateDirection"top" | "right" | "bottom" | "left""right"Direction of rotation