EdgeCases Logo
Nov 2025
CSS
Deep
7 min read

Animating CSS Grid: The Discrete Value Problem

grid-template-columns won't animate—fr units lack computable intermediate values, breaking smooth transitions

css
grid
animations
layout
edge-case
performance
transitions

Add transition: all 0.3s to a grid container, then change grid-template-columns from 1fr 1fr to 1fr 2fr. Nothing animates—the layout snaps instantly. The problem: grid-template-columns is a discrete property. Browsers can't interpolate between 1fr values because fractional units depend on available space, which changes during layout. CSS can only animate properties with computable intermediate values. Grid template definitions lack this, breaking the smooth transitions designers expect.

Why Grid Templates Can't Animate

CSS transitions work by calculating intermediate values between a start state and end state. For numeric properties like width or opacity, this is straightforward:

/* width: Animatable (numeric pixels) */
.box {
  width: 100px;
  transition: width 0.3s;
}

.box:hover {
  width: 200px;
}

/* Browser calculates:
   Frame 1: width: 100px
   Frame 2: width: 110px
   Frame 3: width: 120px
   ...
   Frame N: width: 200px
*/

But grid-template-columns uses non-interpolatable values:

/* grid-template-columns: NOT animatable */
.grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  transition: grid-template-columns 0.3s; /* Ignored! */
}

.grid.expanded {
  grid-template-columns: 1fr 2fr;
}

/* Why it fails:
   - "1fr" is a fraction of remaining space
   - Remaining space depends on container width
   - Container width may change during layout
   - No way to compute intermediate "1.5fr" values
   - Browser gives up: instant snap instead of transition
*/

The Discrete Property Problem

CSS classifies properties as animatable or discrete based on whether intermediate values can be computed. Discrete properties change instantly—no smooth transition possible.

Common discrete properties:

  • display (block → flex: no intermediate state)
  • visibility (visible → hidden: no partial visibility)
  • grid-template-columns (1fr 1fr → 1fr 2fr: non-computable)
  • grid-template-rows (same reason as columns)
  • grid-template-areas (named grid areas can't interpolate)

Animatable alternatives:

  • transform (translate, scale, rotate)
  • opacity (0 to 1)
  • width, height (pixels, %, em)
  • gap (grid/flexbox gap is animatable!)

Why 'fr' Units Break Interpolation

The fr unit represents a fraction of remaining space after fixed-size tracks are allocated. This makes it non-deterministic during animation:

/* Example grid: */
.grid {
  width: 600px;
  grid-template-columns: 100px 1fr 1fr;
}

/* Column widths:
   Column 1: 100px (fixed)
   Remaining space: 600px - 100px = 500px
   Column 2: 500px × (1fr / 2fr) = 250px
   Column 3: 500px × (1fr / 2fr) = 250px
*/

/* Now animate to: */
.grid.expanded {
  grid-template-columns: 100px 1fr 2fr;
}

/* New column widths:
   Column 1: 100px (fixed)
   Remaining space: 500px
   Column 2: 500px × (1fr / 3fr) = 166.67px
   Column 3: 500px × (2fr / 3fr) = 333.33px

   Problem: Browser can't interpolate fractional ratios
   - Intermediate state "1fr 1.5fr" has no meaning
   - Remaining space changes if container resizes during animation
   - No way to compute frame-by-frame values
*/

Workaround 1: Animate Grid Gap Instead

The gap property is animatable because it's a fixed length value. Use gap changes to create visual motion:

<div class="grid-gap-demo">
  <div class="item">1</div>
  <div class="item">2</div>
  <div class="item">3</div>
</div>

<style>
.grid-gap-demo {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  transition: gap 0.3s ease-out;
}

.grid-gap-demo:hover {
  gap: 32px; /* Smooth expansion */
}

/* Result: Items stay same size, spacing grows smoothly */
</style>

Limitation: This only animates spacing, not column widths. Good for subtle hover effects, but doesn't solve layout restructuring.

Workaround 2: Animate Individual Grid Item Widths

Instead of changing the grid template, animate width on grid items. This works because pixel/percentage widths are animatable:

<div class="grid">
  <div class="item item-1">Sidebar</div>
  <div class="item item-2">Main Content</div>
</div>

<style>
.grid {
  display: grid;
  grid-template-columns: auto 1fr; /* Sidebar auto-sizes, content fills */
}

.item-1 {
  width: 200px;
  transition: width 0.3s ease-out;
}

.item-1.collapsed {
  width: 60px; /* Smooth collapse */
}

/* Advantages:
   - width is animatable (pixels)
   - Grid auto-adjusts second column
   - Smooth transition
*/

/* Disadvantages:
   - Doesn't work with pure "1fr 2fr" layouts
   - Requires mixing auto/fixed widths with fr units
*/
</style>

Workaround 3: FLIP Technique with Transform

Use the FLIP technique (First, Last, Invert, Play) to animate grid items with transform instead of changing layout properties. This keeps animations compositor-optimized.

The FLIP Approach

<div class="grid" id="animatedGrid">
  <div class="item">1</div>
  <div class="item">2</div>
  <div class="item">3</div>
</div>

<button onclick="toggleLayout()">Toggle Layout</button>

<style>
.grid {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 16px;
}

.grid.two-column {
  grid-template-columns: 2fr 1fr; /* Third item wraps to new row */
}

.item {
  background: hsl(220, 90%, 50%);
  padding: 40px;
  color: white;
  border-radius: 8px;
}
</style>

<script>
function toggleLayout() {
  const grid = document.getElementById('animatedGrid');
  const items = Array.from(grid.children);

  // FIRST: Record current positions
  const first = items.map(item => item.getBoundingClientRect());

  // LAST: Apply new layout (instant)
  grid.classList.toggle('two-column');

  const last = items.map(item => item.getBoundingClientRect());

  // INVERT + PLAY: Animate each item
  items.forEach((item, i) => {
    const deltaX = first[i].left - last[i].left;
    const deltaY = first[i].top - last[i].top;
    const deltaW = first[i].width / last[i].width;
    const deltaH = first[i].height / last[i].height;

    // Invert: Apply transform to "undo" layout change
    item.style.transform = `
      translate(${deltaX}px, ${deltaY}px)
      scale(${deltaW}, ${deltaH})
    `;
    item.style.transition = 'none';

    // Play: Animate to final position
    requestAnimationFrame(() => {
      item.style.transition = 'transform 0.4s ease-out';
      item.style.transform = 'none';
    });
  });
}
</script>

/* How FLIP works:
   1. FIRST: Measure items at 1fr 1fr 1fr (3 columns)
   2. LAST: Instantly change to 2fr 1fr (2 columns)
   3. INVERT: Use transform to move items back to original positions
   4. PLAY: Animate transform back to identity (final positions)

   Result: Smooth animation despite instant grid template change
   Performance: Only transform animates (compositor thread)
*/

FLIP Performance Characteristics

  • Pros: Compositor-safe (GPU accelerated), smooth 60fps, works with any layout change
  • Cons: Requires JavaScript, measures layout (getBoundingClientRect cost), slight initial jank on large grids

Workaround 4: CSS Custom Properties with Calc

Define column widths using CSS custom properties with fixed units (pixels or percentages). Animate the custom property value:

<div class="grid-custom-props">
  <div class="item">Sidebar</div>
  <div class="item">Content</div>
</div>

<style>
.grid-custom-props {
  --sidebar-width: 200px;
  display: grid;
  grid-template-columns: var(--sidebar-width) 1fr;
  transition: --sidebar-width 0.3s; /* ❌ Doesn't work yet! */
}

.grid-custom-props.collapsed {
  --sidebar-width: 60px;
}

/* Problem: CSS custom properties aren't animatable by default
   Chrome 128+ supports @property for animatable custom props
*/
</style>

/* Solution: Register custom property with @property */
@property --sidebar-width {
  syntax: '<length>';
  initial-value: 200px;
  inherits: false;
}

.grid-custom-props {
  display: grid;
  grid-template-columns: var(--sidebar-width) 1fr;
  transition: --sidebar-width 0.3s ease-out;
}

.grid-custom-props.collapsed {
  --sidebar-width: 60px; /* Now animates smoothly! */
}

/* Browser support: Chrome 85+, Safari 16.4+, Firefox not yet (as of 2025)
   Use @supports for progressive enhancement
*/

Browser Support Edge Case

/* Progressive enhancement */
@supports (animation-timeline: scroll()) {
  /* Modern browsers support @property */
  @property --sidebar-width {
    syntax: '<length>';
    initial-value: 200px;
    inherits: false;
  }

  .grid-custom-props {
    transition: --sidebar-width 0.3s;
  }
}

@supports not (animation-timeline: scroll()) {
  /* Fallback: Instant change for older browsers */
  .grid-custom-props {
    transition: none;
  }
}

Workaround 5: Web Animations API with Interpolation

Use the Web Animations API to manually interpolate between grid column values. This requires JavaScript but gives full control:

const grid = document.querySelector('.grid');

// Animate from "200px 1fr" to "400px 1fr"
grid.animate([
  { gridTemplateColumns: '200px 1fr' },
  { gridTemplateColumns: '400px 1fr' }
], {
  duration: 300,
  easing: 'ease-out',
  fill: 'forwards'
});

/* Browser behavior:
   - Web Animations API treats gridTemplateColumns as discrete
   - Animation snaps at 50% (no smooth interpolation)
   - Same limitation as CSS transitions

   This ONLY works if column values are fixed units (px, %)
*/

/* For fr units, manual interpolation required: */
function animateGridColumns(grid, fromFr, toFr, duration) {
  const start = performance.now();
  const containerWidth = grid.offsetWidth;

  function animate(currentTime) {
    const elapsed = currentTime - start;
    const progress = Math.min(elapsed / duration, 1);
    const eased = easeOutCubic(progress);

    // Interpolate fr ratio
    const currentFr = fromFr + (toFr - fromFr) * eased;

    // Convert to pixels (assumes 2-column layout)
    const col1Width = containerWidth * (1 / (1 + currentFr));
    const col2Width = containerWidth - col1Width;

    grid.style.gridTemplateColumns = `${col1Width}px ${col2Width}px`;

    if (progress < 1) {
      requestAnimationFrame(animate);
    }
  }

  requestAnimationFrame(animate);
}

function easeOutCubic(t) {
  return 1 - Math.pow(1 - t, 3);
}

// Usage: Animate from "1fr 1fr" to "1fr 2fr"
animateGridColumns(grid, 1, 2, 300);

/* Disadvantages:
   - Requires JavaScript
   - Converts fr to px (loses responsive benefits)
   - Must recalculate on window resize
   - More complex than CSS transitions
*/

The Future: interpolate-size and Discrete Transitions

CSS is evolving to support animation of previously discrete properties. The interpolate-size property (CSS Working Draft, 2025) aims to enable smooth transitions for auto and intrinsic sizing values.

/* Future CSS (experimental): */
.grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  transition: grid-template-columns 0.3s;
  interpolate-size: allow-keywords; /* Proposed property */
}

.grid.expanded {
  grid-template-columns: 1fr 2fr; /* May animate in future browsers */
}

/* Status: Not yet implemented (2025)
   Spec: https://drafts.csswg.org/css-values-5/#interpolate-size
   Browser support: None (proposal stage)
*/

Why This Is Hard

Animating fr units requires solving complex problems:

  • Dynamic container sizing: If container width changes during animation, fr ratios recalculate
  • Content intrinsic sizes: minmax(min-content, 1fr) depends on actual content, which may load asynchronously
  • Nested grids: Parent grid changes affect child grid fr calculations
  • Performance: Recalculating layout every frame is expensive (defeats compositor optimization)

Practical Recommendation: Choose Your Battles

Use Case 1: Collapsible Sidebar

Goal: Animate sidebar from 250px to 60px (icon-only mode).

Best approach: Animate grid item width (Workaround 2)

.grid {
  display: grid;
  grid-template-columns: auto 1fr;
}

.sidebar {
  width: 250px;
  transition: width 0.3s ease-out;
}

.sidebar.collapsed {
  width: 60px;
}

/* Why: Simple, performant, works everywhere */

Use Case 2: Dashboard Layout Toggle

Goal: Switch from 3-column to 2-column layout on button click.

Best approach: FLIP technique (Workaround 3)

/* Use JavaScript to measure, invert, and animate with transform
   Keeps animation compositor-safe
   Handles arbitrary layout changes
*/

Use Case 3: Responsive Breakpoints

Goal: Grid changes from 3 columns to 1 column on mobile.

Best approach: No animation (instant snap)

@media (max-width: 768px) {
  .grid {
    grid-template-columns: 1fr; /* Instant, no transition */
  }
}

/* Why: Responsive layout changes should be instant
   Users expect layouts to adapt immediately
   Animation would feel sluggish
*/

Production Checklist

  1. Accept instant snaps for most grid changes: Responsive breakpoints shouldn't animate.
  2. Animate gap instead of columns: If you only need spacing changes, gap is animatable.
  3. Use FLIP for complex layouts: When grid template changes must animate, use FLIP with transform.
  4. Avoid animating width/height: These trigger layout (slow). Prefer transform (compositor).
  5. Test with @property: For fixed-width columns, animate CSS custom properties (Chrome 85+).
  6. Consider Web Animations API: Only if you need JavaScript control and manual interpolation.
  7. Document browser support: @property is not supported in Firefox (2025). Provide fallbacks.
  8. Profile performance: Use Chrome DevTools to verify animations don't cause layout thrashing.

CSS Grid's fr units are powerful for responsive layouts but impossible to animate smoothly. The browser can't compute intermediate values for fractional space allocation. Until interpolate-size ships (if ever), your options are: animate gap instead, use fixed widths with @property, or apply the FLIP technique to fake smooth transitions with transform. Most grid layout changes should snap instantly—save animations for sidebar collapses and dashboard toggles where users expect motion.

Advertisement

Related Insights

Explore related edge cases and patterns

CSS
Deep
CSS Animations vs JavaScript: Layout Thrashing and the FLIP Technique
7 min
CSS
Deep
CSS Animations vs Figma: Production Reality vs Design Prototypes
7 min

Advertisement