Skip to main content
ASoc
Tutorial

Tailwind Translate: 16 Uses, and the One That Had to Be Inline

Centering, drawers and hover swaps across 8 components — plus the containing-block defect a no-op translate-x-0 shipped, and why a computed value can't be a class.

The ASoc Team11 min read

Tailwind's translate-x-* and translate-y-* utilities move an element along an axis without affecting layout — nothing reflows, nothing around it shifts. This storefront uses them 16 times across 8 components, and 7 of those are one idiom: centering. One of them also shipped a visible bug, because a translate does something to its descendants that nothing about the class name suggests.

The short answer

translate-x-<n> and translate-y-<n> compile to CSS translate (v4) / transform: translateX() (v3), moving an element from where layout already put it. Values come from the spacing scale (translate-x-5 → 1.25rem), as fractions of the element's own size (-translate-x-1/2 → -50%), or translate-x-full (100%). Prefix with - to negate. The element's layout box does not move.

The census

16 translate instances, 9 distinct values, 8 components

 4  -translate-y-1/2     3  -translate-x-1/2
 3  translate-x-0        1  translate-x-full
 1  translate-x-5        1  -translate-x-5
 1  translate-y-5        1  -translate-y-5
 1  translate-y-0

Split by what the translate is actually for:

PurposeInstancesWhere
Centering against an anchor7TemplateGallery ×2, NewsletterForm, YourProductsGrid, Testimonials, HeroBackground, PreviewModal
Off-canvas drawer slide3Header
Hover icon swap6Trust

Three jobs, and only the second two are animations. The largest group isn't motion at all.

The centering idiom: 7 of 16 uses, and it isn't animation

// src/components/molecules/TemplateGallery.tsx — the carousel's prev button
className="absolute top-1/2 left-3 grid size-10 -translate-y-1/2 place-items-center rounded-full bg-white/90 ..."

top-1/2 puts the button's top edge at the midpoint of its container. That is not centered — it's half the button's height too low. -translate-y-1/2 pulls it back up by half of its own height, and the two together centre it exactly, without anyone needing to know what that height is.

That last part is the whole reason the idiom exists. Percentage top/left resolve against the parent; percentage translate resolves against the element itself. It's the only pair of properties that can express "half of me" in CSS without measuring, which is why this appears 7 times here and in every codebase:

// src/components/organisms/Testimonials.tsx
<div className="absolute bottom-0 left-1/2 -translate-x-1/2">

// src/components/molecules/HeroBackground.tsx — centered glow
className="absolute top-[-32px] left-1/2 h-[140px] w-[320px] -translate-x-1/2 sm:top-[-56px] sm:h-[180px] sm:w-[520px]"

// src/components/molecules/NewsletterForm.tsx — submit button inside the input
className="focus:shadow-focused absolute top-1/2 right-1 flex h-10 min-w-12 -translate-y-1/2 items-center justify-center rounded-full bg-primary ..."

The HeroBackground case shows why it beats a hard-coded offset: the glow's width changes at sm (w-[320px] → w-[520px]), and -translate-x-1/2 stays correct across both without a second responsive variant. A ml-[-160px] would have needed sm:ml-[-260px] beside it, and would drift the next time the width changed.

Where flexbox or grid already owns the layout, none of this is needed — place-items-center on that same gallery button centres the icon inside it. The translate idiom is specifically for absolute/fixed elements, which have no parent layout to defer to.

The drawer: translate-x-full and the one real slide

// src/components/organisms/Header.tsx
className={`fixed inset-y-0 right-0 z-9999 flex w-[85%] max-w-xs flex-col justify-between overflow-y-auto bg-white px-6 py-8 shadow-2xl transition-[transform,visibility] duration-300 ease-in-out xl:pointer-events-auto xl:visible xl:static xl:inset-auto xl:z-auto xl:max-w-none xl:flex-1 xl:translate-x-0 xl:flex-row ... ${navOpen ? "visible translate-x-0" : "pointer-events-none invisible translate-x-full"}`}

Three things worth pulling out of that string:

translate-x-full is 100% of the element, not of the viewport. The drawer is w-[85%] max-w-xs, so translate-x-full moves it exactly its own width to the right — just past the edge, whatever that width resolves to. A viewport-relative value like translate-x-[100vw] would overshoot on a wide screen and animate for longer than the drawer is visible.

Transform is the right property to animate. transition-[transform,visibility] animates the slide on the compositor; animating right or margin-left instead would relayout on every frame. This is the main practical reason to reach for translate over a positional offset in motion.

invisible is in the transition list, and that was a bug fix. The comment above the class says it: pointer-events-none stops the mouse but not the Tab key, so the closed drawer's links stayed focusable off-screen and a keyboard user on a phone tabbed through invisible nav links. A translate moves an element out of sight but leaves it in the accessibility tree and the tab order. invisible removes it; putting visibility in the transition means the flip happens only after the slide finishes, so the outgoing drawer stays visible while it animates.

The desktop bar is the same element. xl:translate-x-0 unconditionally resets the transform above xl, where the drawer becomes a static horizontal nav. This is why translate-x-0 has 3 uses — a translate often needs explicit cancellation at a breakpoint or state, not just application.

The hover swap: 6 uses, 2 elements, one icon

Trust stacks two copies of the same arrow glyph and slides them past each other on hover:

// src/components/organisms/Trust.tsx
<span className="relative flex h-5 w-5 items-center justify-center overflow-hidden">
  <svg className="absolute inset-0 m-auto h-4 w-4 transition-all duration-300 ease-in-out
                  group-hover:translate-x-5 group-hover:-translate-y-5" ... />
  <svg className="absolute inset-0 m-auto h-4 w-4 -translate-x-5 translate-y-5
                  transition-all duration-300 ease-in-out
                  group-hover:translate-x-0 group-hover:translate-y-0" ... />
</span>

The first arrow sits at rest and exits up-and-right on hover; the second starts parked down-and-left and arrives at 0,0. The effect is one arrow travelling diagonally through the box with no gap. Three details make it work:

  • overflow-hidden on the relative parent clips both arrows at the 5×5 boundary, so neither is visible in transit.
  • The resting offsets (-translate-x-5 translate-y-5) are plain utilities, not group-hover ones — the incoming arrow is translated by default and group-hover:translate-x-0 cancels it. Both arrows are always translated; hover only swaps which values apply.
  • 5 (1.25rem) exceeds the 5×5 box (1.25rem), so each arrow is fully outside the clip at its extreme.

Accounting for all 6: 2 on the outgoing arrow's hover state, 2 resting on the incoming one, 2 on its hover state. No element here is translated once.

The defect: a translate makes the element a containing block for fixed children

This one shipped, and the class name gives no hint of it.

SavedTemplates renders a slide-up sheet. It was originally inline in the header markup, and it rendered visibly broken on mobile — a full-width button spilling across the page and no backdrop at all. The fix is a portal, and the reason is in the code:

// src/components/molecules/SavedTemplates.tsx
{/*
  * PORTALLED TO document.body ON PURPOSE — do not inline this back into
  * the header markup. `Header`'s nav wrapper carries `xl:translate-x-0`
  * (and `translate-x-full` on mobile) for its slide-in drawer, and a
  * non-`none` transform makes that element the containing block for any
  * `position: fixed` descendant. Rendered in place, the overlay and panel
  * size themselves against the nav box instead of the viewport, which
  * shipped as a visibly broken sheet: a full-width "View all saved"
  * button spilling across the page and no backdrop.
  */}
{open && createPortal(<>...</>, document.body)}

Any non-none transform — including translate-x-0 — makes that element the containing block for every position: fixed descendant. fixed inset-0 on the backdrop then means "fill the nav wrapper," not "fill the viewport."

translate-x-0 is the nastiest version, because it is a no-op visually while being a real value in the cascade. Here is what it actually compiles to in this build's CSS:

.xl\:translate-x-0 { --tw-translate-x: 0; translate: var(--tw-translate-x) var(--tw-translate-y) }

translate: 0 — not translate: none. Zero pixels of movement, and a new containing block for every fixed child. (In v4 these utilities set the standalone translate property rather than transform, which changes nothing about this: per the CSS Transforms spec, transform, translate, rotate and scale all establish one when their value isn't none.) The desktop-reset class that makes the drawer work above xl is what broke a sheet nested inside it.

There is no warning for this. The markup is valid, the CSS is valid, and the only symptom is a fixed child sized against the wrong box.

Comparison: moving an element without moving layout

ApproachReflows layoutCompositor-friendlyKnows "half of me"Used here
translate-x-* / translate-y-*NoYesYes (-1/2, full)16×
left-* / top-* on absoluteNo (but repaints)NoNo — resolves against parentAs the anchor, paired with translate
ml-* / mt-*YesNoNoNever for motion
inset-0 + h-full w-fullNo—N/AWhere fill, not offset, is wanted
Keyframes with transformNoYesYes1× — the marquee animation

That last row is the exception to the utility-first rule. The Carousel marquee lives in globals.css, not in a class:

/* src/app/globals.css */
@keyframes marquee {
  from { transform: translateX(0); }
  to { transform: translateX(-100%); }
}

A utility can set one static value per state. A continuous loop needs two keyframes and no state change between them, which is not a thing a class can express — so it becomes a keyframe that happens to use the same CSS property.

The one translate that cannot be a utility

// src/components/molecules/TemplateGallery.tsx
<div
  className="flex transition-transform duration-500 ease-out motion-reduce:transition-none"
  ref={trackRef}
  style={{ transform: `translateX(-${index * 100}%)` }}
>

The gallery track slides by -100% per slide index — an inline style, in a codebase that is otherwise entirely utility classes. It has to be, and the reason is Tailwind's build model: class names are discovered by scanning source files as plain text. translate-x-[-${index * 100}%] is not a class name in the source; it's a template literal that only becomes one at runtime, long after the CSS was generated. The utility would never be emitted and the element would never move.

The rule is that an arbitrary value must be literally written out to exist. A value computed per render belongs in style, and that is the correct call rather than a workaround — the alternative is enumerating -translate-x-[0%] through -translate-x-[300%] as safelisted classes for a carousel whose slide count comes from the catalog.

Worth noting what stays a utility here: transition-transform duration-500 ease-out and motion-reduce:transition-none. Only the value is dynamic, so only the value goes inline — the animation's shape, and the reduced-motion opt-out, remain classes.

Troubleshooting

SymptomCauseFix
A fixed child sizes itself against a parent, not the viewportAn ancestor has a non-none transform — even translate-x-0Portal the child to document.body, or remove the transform
-translate-x-1/2 centers nothingNo left-1/2 (or left-[50%]) anchor beside itThe idiom is both halves: left-1/2 -translate-x-1/2
translate-x-full overshoots or undershootsfull is 100% of the element, not the viewportIntended for off-canvas by own width; use a viewport unit if you really mean the screen
Off-screen translated links are still tabbableTranslate moves pixels, not the tab orderAdd invisible (and put visibility in the transition so it flips after the slide)
A dynamic translate-x-[${n}%] does nothingTailwind scans source text; a runtime-built class was never generatedUse an inline style for the computed value
The slide animation stuttersA layout property (left, margin) is being animatedAnimate transform instead — transition-[transform]
A translate persists at a larger breakpointTransforms aren't reset by a breakpoint automaticallyCancel it explicitly, e.g. xl:translate-x-0
An element overlaps its neighbour after translatingExpected — translate doesn't change the layout boxUse margin or a layout change if siblings must move too

Frequently asked questions

What's the difference between translate-x-full and translate-x-[100%] in Tailwind? Nothing — both resolve to 100% of the element's own width. full is the named shorthand for the fractional scale (1/2, full), which measures against the element itself, unlike left-* percentages which measure against the parent.

Why do left-1/2 and -translate-x-1/2 have to be used together? They resolve against different boxes. left-1/2 puts the element's left edge at the parent's midpoint; -translate-x-1/2 then pulls it back by half of its own width. Only the second one can express "half of me" without knowing the element's size, which is what makes the pair work for content of unknown width.

Does a translate affect surrounding elements? No. The element's layout box stays exactly where layout put it, so nothing reflows and nothing shifts — which is also why a translated element can overlap its neighbours. If siblings need to move too, the translate is the wrong tool.

Can I use a computed value in a Tailwind translate class? Not if it's computed at runtime. Arbitrary values must appear literally in the source for the class scanner to find them, so a template-literal class name is never generated. Put the computed transform in an inline style and keep the transition and reduced-motion handling as classes, the way this repo's carousel track does.

Templates in this post

ASoc Reach, ASoc Realm and ASoc Relay are Next.js + Tailwind landing page templates built on the same transform conventions audited above — left-1/2 -translate-x-1/2 for anchored centering, translate-x-full drawers that animate on the compositor, and motion-reduce opt-outs on every transition.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial11 min read

Multi-Tenant Theming with Tailwind CSS v4 and CSS Variables

Tailwind v4 tokens compile to real CSS custom properties, so one build can serve every tenant's brand. The override pattern, contrast handling, and the pitfalls.

Read more