Skip to content

feat(website): remove cls on typewritter effect - #1293

Open
jonatankruszewski wants to merge 1 commit into
ealush:latestfrom
jonatankruszewski:remove-cls-docs
Open

feat(website): remove cls on typewritter effect#1293
jonatankruszewski wants to merge 1 commit into
ealush:latestfrom
jonatankruszewski:remove-cls-docs

Conversation

@jonatankruszewski

@jonatankruszewski jonatankruszewski commented Apr 16, 2026

Copy link
Copy Markdown
Q A
Bug fix?
New feature?
Breaking change?
Deprecations?
Documentation?
Tests added?
Types added?
Related issues

This fixes layout shifts caused by the homepage typewriter changing its rendered dimensions while phrases are typed or when a longer phrase is selected.

The Typewriter component now renders an invisible sizing layer with the full reserved text variants and overlays the visible animated text in the same grid position. This lets the component reserve the required space up front while keeping the existing typing animation behavior.

The homepage passes all available hero typewriter phrases to Typewriter through reservePairs, so the hero title reserves enough space for any randomly selected phrase group.

Summary by CodeRabbit

  • New Features

    • Typewriter accepts customizable variant pairs so multiple prefix/value combinations can be measured and displayed more reliably.
    • Prefix cursor no longer blinks while value cursor still animates for clearer emphasis.
  • Style

    • Improved layout with hidden sizing layer to stabilize text wrapping and spacing.
    • Responsive adjustments for better mobile line-breaking and consistent sizing.

@vercel

vercel Bot commented Apr 16, 2026

Copy link
Copy Markdown

@jonatankruszewski is attempting to deploy a commit to the ealush's projects Team on Vercel.

A member of the Team first needs to authorize it.

@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Fix typewriter layout shifts with invisible sizing layer

🐞 Bug fix ✨ Enhancement

Grey Divider

Walkthroughs

Description
• Fixes layout shifts in typewriter component by reserving space upfront
• Implements invisible sizing layer with all phrase variants
• Overlays visible animated text on reserved space grid
• Adds responsive breakpoint handling for mobile devices
Diagram
flowchart LR
  A["Typewriter Component"] -->|receives reservePairs| B["Generate Reserve Variants"]
  B -->|all phrase combinations| C["Invisible Sizer Layer"]
  C -->|reserves space| D["Grid Layout"]
  D -->|overlays| E["Visible Animated Text"]
  E -->|maintains animation| F["No Layout Shift"]
Loading

Grey Divider

File Changes

1. website/src/components/Typewriter/index.js 🐞 Bug fix +44/-13

Restructure component with space-reserving grid layout

• Added reservePairs prop to accept phrase variants for space reservation
• Implemented reserveVariants logic to flatten all prefix/value combinations
• Restructured JSX to use grid layout with invisible sizer and visible text layers
• Moved cursor and breakpoint elements into both sizer and visible text sections

website/src/components/Typewriter/index.js


2. website/src/pages/index.js ✨ Enhancement +1/-0

Pass phrase data to typewriter component

• Passed TYPEWRITER_DATA to Typewriter component via reservePairs prop
• Enables component to reserve space for all possible hero phrases

website/src/pages/index.js


3. website/src/components/Typewriter/styles.module.css Styling +33/-0

Add grid layout and responsive breakpoint styles

• Added grid-based layout styles for .typewriter container
• Created .sizer and .visibleText layers sharing same grid area
• Added .sizerVariant for individual phrase variants in sizer
• Implemented .breakPoint with newline content for line breaks
• Added responsive media query to hide breakpoints on mobile

website/src/components/Typewriter/styles.module.css


Grey Divider

Qodo Logo

@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Added a reservePairs prop to the Typewriter component and refactored its rendering to use a CSS Grid layout with a hidden sizer layer and a visible text layer; updated homepage to pass TYPEWRITER_DATA into the component and added supporting CSS rules.

Changes

Cohort / File(s) Summary
Typewriter Component
website/src/components/Typewriter/index.js
Added reservePairs prop and memoized reserveVariants. Reworked rendering from fragments to a grid-wrapped <span> with hidden sizing variants and visible text layer; adjusted cursor markup and stable keys for sizing variants.
Typewriter Styles
website/src/components/Typewriter/styles.module.css
Added .typewriter, .sizer, .visibleText, and .sizerVariant to support overlapping grid measurement/display. Implemented .breakPoint::before for hard line breaks with a mobile media-query override.
Homepage Integration
website/src/pages/index.js
Passed reservePairs={TYPEWRITER_DATA} into Typewriter from HomepageHeader to supply full variant dataset at runtime.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

codex

Poem

🐇 I nibbled keys and typed a tune,

reserve of lines beneath the moon;
Hidden grids that measure wide,
Visible words that hop with pride. ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The PR title mentions removing 'cls' from typewriter effect, but the actual changes focus on fixing layout shifts by adding a hidden sizing layer with reserved text variants and repositioning the visible text via CSS grid, not removing classes. Update the title to accurately reflect the main change, such as 'fix(website): prevent typewriter layout shifts with reserved sizing' or similar, to match the actual implementation.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review

qodo-code-review Bot commented Apr 16, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Remediation recommended

1. Indented value line 🐞 Bug ≡ Correctness
Description
The value line starts with a literal leading space on desktop because the breakPoint span renders a
real space while CSS injects a newline via ::before. This can visibly misalign/indent the
highlighted second line compared to the previous <br/> behavior.
Code

website/src/components/Typewriter/index.js[R165-169]

+        {showValueCursor && (
+          <>
+            <span className={styles.breakPoint}> </span>
+            <span className={highlightClassName}>
+              {displayedValue}
Evidence
In the visible text, a breakPoint span contains a literal space, and the CSS adds a newline before
it; that space becomes the first character on the second line on non-mobile widths.

website/src/components/Typewriter/index.js[165-178]
website/src/components/Typewriter/styles.module.css[23-27]
website/src/components/Typewriter/styles.module.css[49-53]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The component uses `<span className={styles.breakPoint}> </span>` and CSS `.breakPoint::before { content: '\A'; }`, which causes a real leading space at the start of the second line on desktop.

## Issue Context
The literal space is currently used to create a separator on mobile (where the newline is disabled), but on desktop it becomes indentation.

## Fix Focus Areas
- website/src/components/Typewriter/index.js[140-180]
- website/src/components/Typewriter/styles.module.css[23-53]

## Suggested fix
- Make the breakPoint element empty in both sizer and visible layers: `<span className={styles.breakPoint} />`.
- Encode the separator entirely in CSS:
 - Default (desktop): `.breakPoint::before { content: '\A'; white-space: pre; }`
 - Mobile override: `.breakPoint::before { content: ' '; white-space: normal; }`
This preserves the desktop line break and the mobile inline space without introducing indentation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Sizer rerenders every tick 🐞 Bug ➹ Performance
Description
The hidden sizing layer rebuilds the full reserveVariants subtree on every typing tick, increasing
JS/VDOM work during frequent state updates. With enough reserved variants or on slower devices, this
can reduce animation smoothness.
Code

website/src/components/Typewriter/index.js[R132-157]

+  const reserveVariants = (reservePairs ?? [[prefix, values]]).flatMap(
+    ([reservePrefix, reserveValues]) =>
+      (reserveValues.length ? reserveValues : ['']).map(reserveValue => ({
+        prefix: reservePrefix,
+        value: reserveValue,
+      })),
+  );
+
  return (
-    <>
-      {displayedPrefix}
-      {showPrefixCursor && (
-        <span className={clsx(styles.cursor, { [styles.blinking]: false })}>
-          |
-        </span>
-      )}
-      <br />
-      <span className={highlightClassName}>
-        {displayedValue}
-        {showValueCursor && (
+    <span className={styles.typewriter}>
+      <span className={styles.sizer} aria-hidden="true">
+        {reserveVariants.map(({ prefix, value }, index) => (
          <span
-            className={clsx(styles.cursor, { [styles.blinking]: isBlinking })}
+            className={styles.sizerVariant}
+            key={`${prefix}-${value}-${index}`}
          >
+            {prefix}
+            <span className={styles.cursor}>|</span>
+            <span className={styles.breakPoint}> </span>
+            <span className={highlightClassName}>
+              {value}
+              <span className={styles.cursor}>|</span>
+            </span>
+          </span>
+        ))}
+      </span>
Evidence
Typewriter updates state on a tight timer loop, triggering re-renders; the new implementation always
recalculates reserveVariants and maps it into a sizable hidden DOM subtree on each render, even
though it usually doesn’t change while typing.

website/src/components/Typewriter/index.js[28-111]
website/src/components/Typewriter/index.js[132-157]
website/src/components/Typewriter/data.js[1-70]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The component re-renders on every typing tick and rebuilds the `reserveVariants.map(...)` VDOM tree each time, even when the sizing content is unchanged.

## Issue Context
The homepage passes `TYPEWRITER_DATA` (~27 variants) as `reservePairs`. The typing loop can update every ~30–70ms, so avoiding unnecessary list/element reconstruction helps keep the animation smooth.

## Fix Focus Areas
- website/src/components/Typewriter/index.js[28-111]
- website/src/components/Typewriter/index.js[132-157]

## Suggested fix
- Memoize the derived variants and/or the sizer subtree:
 - `const reserveVariants = useMemo(() => ..., [reservePairs, prefix, values]);`
 - Optionally memoize the rendered sizer element:
   - `const sizer = useMemo(() => (<span ...>{reserveVariants.map(...)}</span>), [reserveVariants, highlightClassName]);`
- Alternatively, extract a `Sizer` child component wrapped in `React.memo` that only receives `reservePairs/prefix/values/highlightClassName` so it doesn’t re-render on every character tick.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

3. Empty reservePairs breaks sizing 🐞 Bug ☼ Reliability
Description
Passing reservePairs={[]} results in an empty sizing layer because ?? does not fall back for
empty arrays, which can reintroduce layout shifts. This is a subtle API footgun if callers build
reservePairs dynamically (e.g., before data loads).
Code

website/src/components/Typewriter/index.js[R132-138]

+  const reserveVariants = (reservePairs ?? [[prefix, values]]).flatMap(
+    ([reservePrefix, reserveValues]) =>
+      (reserveValues.length ? reserveValues : ['']).map(reserveValue => ({
+        prefix: reservePrefix,
+        value: reserveValue,
+      })),
+  );
Evidence
The sizing logic only falls back when reservePairs is null/undefined; an empty array is treated as a
valid value and produces no reserveVariants, so the .sizer subtree renders nothing.

website/src/components/Typewriter/index.js[132-138]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`reservePairs ?? [[prefix, values]]` treats `[]` as a valid value, so callers who pass an empty array will get zero reserved variants and no sizing.

## Issue Context
This can happen if a parent computes reservePairs from data that starts empty and is populated later, defeating the CLS fix.

## Fix Focus Areas
- website/src/components/Typewriter/index.js[132-138]

## Suggested fix
Change the fallback to also handle empty arrays:
- `const pairs = reservePairs?.length ? reservePairs : [[prefix, values]];`
- Then compute `reserveVariants` from `pairs`.
Optionally add a lightweight runtime guard that ensures each entry is `[string, string[]]` to avoid `.map` crashes if a wrong shape is passed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a sizer mechanism to the Typewriter component to prevent layout shifts during animations by pre-calculating the maximum required space. Key changes include the addition of a reservePairs prop, a grid-based layout for overlapping hidden and visible text, and responsive breakpoint handling. Review feedback suggests memoizing the variant calculations to optimize performance during frequent re-renders and refining the breakpoint implementation by moving space characters into CSS to ensure consistent alignment across desktop and mobile views.

Comment thread website/src/components/Typewriter/index.js Outdated
Comment thread website/src/components/Typewriter/index.js Outdated
Comment thread website/src/components/Typewriter/index.js Outdated
Comment thread website/src/components/Typewriter/styles.module.css

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
website/src/components/Typewriter/index.js (2)

132-138: Optional: memoize reserveVariants.

reserveVariants is rebuilt on every render, and the component re-renders on every typing tick (~30–70ms). The cost is small for today’s TYPEWRITER_DATA, but wrapping in useMemo keyed on reservePairs/prefix/values makes the work proportional to actual prop changes and also lets React skip reconciling the sizer subtree across ticks when memoized further down.

♻️ Proposed tweak
-  const reserveVariants = (reservePairs ?? [[prefix, values]]).flatMap(
-    ([reservePrefix, reserveValues]) =>
-      (reserveValues.length ? reserveValues : ['']).map(reserveValue => ({
-        prefix: reservePrefix,
-        value: reserveValue,
-      })),
-  );
+  const reserveVariants = React.useMemo(
+    () =>
+      (reservePairs ?? [[prefix, values]]).flatMap(
+        ([reservePrefix, reserveValues]) =>
+          (reserveValues.length ? reserveValues : ['']).map(reserveValue => ({
+            prefix: reservePrefix,
+            value: reserveValue,
+          })),
+      ),
+    [reservePairs, prefix, values],
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@website/src/components/Typewriter/index.js` around lines 132 - 138, Wrap the
computation of reserveVariants in React's useMemo so it only rebuilds when
inputs change: memoize the expression that uses reservePairs, prefix and values
(the current flatMap producing objects with prefix/value) by replacing the
direct declaration of reserveVariants with a useMemo that lists reservePairs,
prefix, and values as dependencies; this reduces work on each typing tick and
lets downstream memoization of the sizer subtree be effective.

141-164: Minor cleanups: dead clsx condition and variable shadowing.

Two small readability items in the render tree:

  • Line 161: clsx(styles.cursor, { [styles.blinking]: false }) always resolves to just styles.cursor — the prefix cursor never blinks, so the conditional entry is dead.
  • Line 143: destructuring { prefix, value } inside the map shadows the outer prefix prop. It works today because the map body only needs the inner values, but it’s easy to trip over in future edits.
♻️ Proposed tweak
-      <span className={styles.sizer} aria-hidden="true">
-        {reserveVariants.map(({ prefix, value }, index) => (
-          <span
-            className={styles.sizerVariant}
-            key={`${prefix}-${value}-${index}`}
-          >
-            {prefix}
-            <span className={styles.cursor}>|</span>
-            <span className={styles.breakPoint}> </span>
-            <span className={highlightClassName}>
-              {value}
-              <span className={styles.cursor}>|</span>
-            </span>
-          </span>
-        ))}
-      </span>
+      <span className={styles.sizer} aria-hidden="true">
+        {reserveVariants.map(
+          ({ prefix: variantPrefix, value: variantValue }, index) => (
+            <span
+              className={styles.sizerVariant}
+              key={`${variantPrefix}-${variantValue}-${index}`}
+            >
+              {variantPrefix}
+              <span className={styles.cursor}>|</span>
+              <span className={styles.breakPoint}> </span>
+              <span className={highlightClassName}>
+                {variantValue}
+                <span className={styles.cursor}>|</span>
+              </span>
+            </span>
+          ),
+        )}
+      </span>
       <span className={styles.visibleText}>
         {displayedPrefix}
         {showPrefixCursor && (
-          <span className={clsx(styles.cursor, { [styles.blinking]: false })}>
-            |
-          </span>
+          <span className={styles.cursor}>|</span>
         )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@website/src/components/Typewriter/index.js` around lines 141 - 164, The
render contains two small issues to fix: in the reserveVariants.map callback
avoid shadowing the outer prefix prop by renaming the inner destructured
variables (e.g., change "{ prefix, value }" to "{ prefix: variantPrefix, value:
variantValue }" or similar) so the outer prefix prop remains unshadowed; and
simplify the dead clsx call used for the prefix cursor by removing the
always-false conditional object (replace "clsx(styles.cursor, {
[styles.blinking]: false })" with just styles.cursor or a clsx call that only
includes styles.blinking when a real condition is present) so the cursor class
isn’t cluttered with a no-op entry.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@website/src/components/Typewriter/index.js`:
- Around line 132-138: Wrap the computation of reserveVariants in React's
useMemo so it only rebuilds when inputs change: memoize the expression that uses
reservePairs, prefix and values (the current flatMap producing objects with
prefix/value) by replacing the direct declaration of reserveVariants with a
useMemo that lists reservePairs, prefix, and values as dependencies; this
reduces work on each typing tick and lets downstream memoization of the sizer
subtree be effective.
- Around line 141-164: The render contains two small issues to fix: in the
reserveVariants.map callback avoid shadowing the outer prefix prop by renaming
the inner destructured variables (e.g., change "{ prefix, value }" to "{ prefix:
variantPrefix, value: variantValue }" or similar) so the outer prefix prop
remains unshadowed; and simplify the dead clsx call used for the prefix cursor
by removing the always-false conditional object (replace "clsx(styles.cursor, {
[styles.blinking]: false })" with just styles.cursor or a clsx call that only
includes styles.blinking when a real condition is present) so the cursor class
isn’t cluttered with a no-op entry.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d9473cec-707a-45aa-8c5c-be0195a42d9c

📥 Commits

Reviewing files that changed from the base of the PR and between 7e44d61 and 22450b9.

📒 Files selected for processing (3)
  • website/src/components/Typewriter/index.js
  • website/src/components/Typewriter/styles.module.css
  • website/src/pages/index.js

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
website/src/components/Typewriter/index.js (1)

132-142: Minor: brittle assumption on reservePairs shape.

reserveValues.length (Line 136) will throw if a caller passes a pair whose second element is undefined (e.g. [[someLabel]]). For the current homepage usage with TYPEWRITER_DATA this should be safe, but a tiny defensive default keeps this robust if the dataset shape ever drifts.

🛡️ Suggested defensive handling
-      (reservePairs ?? [[prefix, values]]).flatMap(
-        ([reservePrefix, reserveValues]) =>
-          (reserveValues.length ? reserveValues : ['']).map(reserveValue => ({
-            prefix: reservePrefix,
-            value: reserveValue,
-          })),
-      ),
+      (reservePairs ?? [[prefix, values]]).flatMap(
+        ([reservePrefix = prefix, reserveValues = []]) =>
+          (reserveValues.length ? reserveValues : ['']).map(reserveValue => ({
+            prefix: reservePrefix,
+            value: reserveValue,
+          })),
+      ),

This also aligns the behavior with the summary's description of falling back to the component's prefix when reservePrefix is missing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@website/src/components/Typewriter/index.js` around lines 132 - 142, The code
assumes each pair in reservePairs has a second element array and accesses
reserveValues.length which can throw; update the useMemo mapping in
reserveVariants to defensively coerce reserveValues to an array (e.g., const rv
= Array.isArray(reserveValues) ? reserveValues : ['']) and use rv.length when
deciding to map, and also fall back to the component-level prefix when
reservePrefix is falsy (use prefix). Adjust references to reservePairs,
reservePrefix, reserveValues, prefix, and values inside the reserveVariants
computation accordingly so undefined or malformed pairs don't throw.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@website/src/components/Typewriter/index.js`:
- Around line 132-142: The code assumes each pair in reservePairs has a second
element array and accesses reserveValues.length which can throw; update the
useMemo mapping in reserveVariants to defensively coerce reserveValues to an
array (e.g., const rv = Array.isArray(reserveValues) ? reserveValues : ['']) and
use rv.length when deciding to map, and also fall back to the component-level
prefix when reservePrefix is falsy (use prefix). Adjust references to
reservePairs, reservePrefix, reserveValues, prefix, and values inside the
reserveVariants computation accordingly so undefined or malformed pairs don't
throw.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2b9a8ff2-13b4-4703-92ac-4ce5de31d988

📥 Commits

Reviewing files that changed from the base of the PR and between 22450b9 and f693f41.

📒 Files selected for processing (3)
  • website/src/components/Typewriter/index.js
  • website/src/components/Typewriter/styles.module.css
  • website/src/pages/index.js
✅ Files skipped from review due to trivial changes (1)
  • website/src/pages/index.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • website/src/components/Typewriter/styles.module.css

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant