Skip to content

feat: add slide-in/fade-out animation for Toast notifications#1740

Open
pixeltannu wants to merge 1 commit into
Karanjot786:mainfrom
pixeltannu:feat/toast-animation
Open

feat: add slide-in/fade-out animation for Toast notifications#1740
pixeltannu wants to merge 1 commit into
Karanjot786:mainfrom
pixeltannu:feat/toast-animation

Conversation

@pixeltannu

@pixeltannu pixeltannu commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Closes #1732

Changes

  • Added createdAt timestamp to ToastMessage interface
  • Added animationMs option to ToastOptions
  • Added _getAnimationProgress() method for slide-in/fade-out effect
  • Toast now slides in character by character (0 to 300ms)
  • Toast fades out smoothly in last 300ms before expiry
  • Uses setTimeout with 16ms interval for ~60fps re-render

Summary by CodeRabbit

Release Notes

  • New Features

    • Toast notifications now display with smooth animations.
    • Animation duration is configurable per toast message.
  • Improvements

    • Toast messages now reveal progressively for enhanced visual feedback during display.

@pixeltannu pixeltannu requested a review from Karanjot786 as a code owner June 22, 2026 05:11
@github-actions github-actions Bot added type:feature +10 pts. New feature. area:ui @termuijs/ui and removed type:feature +10 pts. New feature. labels Jun 22, 2026
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

ToastMessage gains a createdAt field and ToastOptions adds animationMs. The Toast widget stores _animationMs (default 300), records createdAt/expireAt on push, and introduces _getAnimationProgress. _renderSelf now truncates each visible label to progress * tw columns and reschedules redraws at ~60 fps while messages are animating.

Changes

Toast Slide-in Animation

Layer / File(s) Summary
ToastMessage, ToastOptions, and icon constants
packages/ui/src/Toast.ts
ToastMessage adds createdAt: number; ToastOptions adds animationMs?: number; toast icon unicode glyphs are updated.
Constructor, push, and screen-reader announcement
packages/ui/src/Toast.ts
Toast stores _animationMs (defaulting to 300). push captures a shared now for both createdAt and expireAt. Screen-reader OSC sequence construction is simplified.
Animation progress helper and animated _renderSelf
packages/ui/src/Toast.ts
_getAnimationProgress(createdAt, expireAt) returns a 0–1 value. _renderSelf truncates each label to progress * tw columns and loops via setTimeout(..., 16)markDirty() while messages are still animating.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 Hop hop, the toasts now glide in smooth,
No more abrupt pop — they're in the groove!
A createdAt tick, a progress to track,
Each label reveals itself, then fades back.
With 16ms frames the rabbit agrees —
Animations are better, done with such ease! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is incomplete. It lacks several required sections including package info, type of change checkboxes, and the comprehensive checklist items required by the template. Complete the PR description by adding 'Which package(s)?' section (answer: @termuijs/ui), checking appropriate 'Type of Change' box (Feature), and confirming all checklist items have been reviewed.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and specifically describes the main change: adding slide-in/fade-out animation for Toast notifications.
Linked Issues check ✅ Passed The code changes fully implement the objectives from issue #1732: adds createdAt tracking, animationMs option, slide-in/fade-out animations, and uses 16ms intervals for smooth rendering.
Out of Scope Changes check ✅ Passed All changes are focused on Toast animation implementation. No unrelated refactoring or out-of-scope modifications detected in the file changes summary.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ 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.

@github-actions github-actions Bot added the type:feature +10 pts. New feature. label Jun 22, 2026

@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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/ui/src/Toast.ts`:
- Around line 55-58: The _announceToScreenReader method embeds the raw text
parameter directly into an OSC control sequence on line 57, which is a security
risk as control characters like ESC, BEL, or ST in the text could break the
sequence or inject malicious commands. Sanitize the text parameter by escaping
or removing control bytes and special characters before concatenating it into
the OSC notification sequence to ensure only safe characters are embedded in the
terminal control code.
- Around line 91-97: The timeout scheduling in the animation check loop creates
a new timer every render cycle when anyAnimating is true, causing timer stacking
and unnecessary markDirty() calls. Add a property to track the pending animation
timer ID (for example, _pendingAnimationTimerId), and only schedule a new
setTimeout call when no timer is already pending. When the timer fires and calls
markDirty(), reset the tracking property to null or clear it so the next
animation cycle can schedule a fresh timer if needed.
- Around line 76-80: The calculation of tw on line 76 can produce zero or
negative values when width is less than 3, which causes incorrect positioning in
the sx calculation on line 79 and potential toast overlap issues. Add a guard
clause before the tw variable assignment to check if width is less than 3, and
if so, return early from the function since the widget geometry is unusable for
rendering. This ensures sx positioning calculations only proceed with valid,
non-negative text width values.
- Line 89: The COLORS object on line 89 is typed as Record<ToastType, string>
but should be explicitly typed as Record<ToastType, NamedColor> since all values
('cyan', 'green', 'yellow', 'red') are valid NamedColor values. Change the type
definition of the COLORS map from Record<ToastType, string> to Record<ToastType,
NamedColor>, then remove the unnecessary as any cast from the usage on line 89
where COLORS[m.type] is referenced. Ensure the NamedColor type is imported from
the appropriate module if not already present.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 963ce9c8-d29e-4aab-a16c-cdbda343ebfc

📥 Commits

Reviewing files that changed from the base of the PR and between 35c2213 and a3711f3.

📒 Files selected for processing (1)
  • packages/ui/src/Toast.ts

Comment thread packages/ui/src/Toast.ts
Comment on lines +55 to +58
private _announceToScreenReader(text: string, _type: ToastType): void {
try {
process.stderr.write('\x1b]777;notify;TermUI;[' + text + ']\x07');
} catch { }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Escape toast text before writing OSC notification sequence

Line 57 embeds raw text into an OSC control sequence. If text contains ESC/BEL/ST characters, it can terminate/inject terminal control codes. Sanitize control bytes before concatenation.

Suggested fix
   private _announceToScreenReader(text: string, _type: ToastType): void {
     try {
-      process.stderr.write('\x1b]777;notify;TermUI;[' + text + ']\x07');
+      const safeText = text.replace(/[\x07\x1b\x9c]/g, '');
+      process.stderr.write('\x1b]777;notify;TermUI;[' + safeText + ']\x07');
     } catch { }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` around lines 55 - 58, The _announceToScreenReader
method embeds the raw text parameter directly into an OSC control sequence on
line 57, which is a security risk as control characters like ESC, BEL, or ST in
the text could break the sequence or inject malicious commands. Sanitize the
text parameter by escaping or removing control bytes and special characters
before concatenating it into the OSC notification sequence to ensure only safe
characters are embedded in the terminal control code.

Comment thread packages/ui/src/Toast.ts
Comment on lines +76 to +80
const tw = Math.min(40, width - 2);
const isRight = this._position.includes('right');
const isBottom = this._position.includes('bottom');
const sx = isRight ? x + width - tw - 1 : x + 1;
const sy = isBottom ? y + height - visible.length - 1 : y + 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard narrow widget widths before computing toast text width

Line 76 can produce tw <= 0 when width < 3, which then skews sx (Line 79) and can cause incorrect draw coordinates/overlap. Clamp width and early-return on unusable geometry.

Suggested fix
   protected _renderSelf(screen: Screen): void {
     const now = Date.now();
     this._messages = this._messages.filter(m => m.expireAt > now);
     if (this._messages.length === 0) return;
     const { x, y, width, height } = this._rect;
+    if (width < 3 || height < 2) return;
     const visible = this._messages.slice(-this._maxVisible);
-    const tw = Math.min(40, width - 2);
+    const tw = Math.max(1, Math.min(40, width - 2));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` around lines 76 - 80, The calculation of tw on line
76 can produce zero or negative values when width is less than 3, which causes
incorrect positioning in the sx calculation on line 79 and potential toast
overlap issues. Add a guard clause before the tw variable assignment to check if
width is less than 3, and if so, return early from the function since the widget
geometry is unusable for rendering. This ensures sx positioning calculations
only proceed with valid, non-negative text width values.

Comment thread packages/ui/src/Toast.ts
const fullLabel = (' ' + icons[m.type] + ' ' + m.text + ' ').slice(0, tw).padEnd(tw);
const visibleChars = Math.floor(progress * tw);
const label = fullLabel.slice(0, visibleChars).padEnd(tw);
screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify assertion/any usage in the changed file and strict-mode declaration in repo tsconfig(s)
rg -n --type=ts '\bas any\b|@\s*ts-ignore|as\s+[A-Za-z0-9_<>{}\[\]\|]+' packages/ui/src/Toast.ts
fd -i '^tsconfig(\..+)?\.json$' | xargs -r rg -n '"strict"\s*:\s*true'

Repository: Karanjot786/TermUI

Length of output: 635


🏁 Script executed:

# Get context around line 89 in Toast.ts
sed -n '80,100p' packages/ui/src/Toast.ts

# Find where COLORS is defined
rg -n 'COLORS\s*[:=]' packages/ui/src/Toast.ts

# Check the entire file structure
wc -l packages/ui/src/Toast.ts

Repository: Karanjot786/TermUI

Length of output: 1151


🏁 Script executed:

# Get the top of Toast.ts to see imports and type definitions
head -30 packages/ui/src/Toast.ts

# Find ToastType definition
rg -n 'type ToastType|enum ToastType' packages/ui/src/Toast.ts

Repository: Karanjot786/TermUI

Length of output: 1174


🏁 Script executed:

# Look for Color type definition in the `@termuijs/core` package
find packages -name "*.ts" -o -name "*.tsx" | xargs rg -l "type Color|interface Color" | head -5

# Search for the Color type more broadly
rg -A 5 "type Color\s*=|interface Color\s*\{" packages/core/src/

Repository: Karanjot786/TermUI

Length of output: 650


🏁 Script executed:

# Find NamedColor type definition
rg -A 5 "type NamedColor" packages/core/src/style/Color.ts

Repository: Karanjot786/TermUI

Length of output: 311


Type COLORS as Record<ToastType, NamedColor> to eliminate the as any cast

Line 89 uses COLORS[m.type] as any unnecessarily. The COLORS map should be explicitly typed as Record<ToastType, NamedColor> instead of Record<ToastType, string>. All values ('cyan', 'green', 'yellow', 'red') are valid NamedColor values, so the cast can be removed entirely. This eliminates the type assertion that violates the no-any/no-assertions-without-comments guidelines.

Suggested change
-const COLORS: Record<ToastType, string> = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' };
+const COLORS: Record<ToastType, NamedColor> = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' };

Then remove the as any cast on line 89:

-      screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });
+      screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] }, bold: true });

Also add the import for NamedColor if not already present:

-import { type Screen, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '`@termuijs/core`';
+import { type Screen, type NamedColor, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '`@termuijs/core`';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });
import { type Screen, type NamedColor, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '`@termuijs/core`';
Suggested change
screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });
const COLORS: Record<ToastType, NamedColor> = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' };
Suggested change
screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });
screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] }, bold: true });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` at line 89, The COLORS object on line 89 is typed
as Record<ToastType, string> but should be explicitly typed as Record<ToastType,
NamedColor> since all values ('cyan', 'green', 'yellow', 'red') are valid
NamedColor values. Change the type definition of the COLORS map from
Record<ToastType, string> to Record<ToastType, NamedColor>, then remove the
unnecessary as any cast from the usage on line 89 where COLORS[m.type] is
referenced. Ensure the NamedColor type is imported from the appropriate module
if not already present.

Source: Coding guidelines

Comment thread packages/ui/src/Toast.ts
Comment on lines +91 to +97
const anyAnimating = visible.some(m => {
const elapsed = now - m.createdAt;
const remaining = m.expireAt - now;
return elapsed < this._animationMs || remaining < this._animationMs;
});
if (anyAnimating) setTimeout(() => this.markDirty(), 16);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid scheduling overlapping animation timers every render

Lines 91-97 schedule a new timeout whenever anyAnimating is true. Repeated renders can stack multiple pending timers and cause redundant markDirty() churn. Track one pending timer ID and only schedule when none exists.

Suggested fix
 export class Toast extends Widget {
   private _messages: ToastMessage[] = [];
+  private _animationTimer: ReturnType<typeof setTimeout> | null = null;
@@
-    if (anyAnimating) setTimeout(() => this.markDirty(), 16);
+    if (anyAnimating && this._animationTimer === null) {
+      this._animationTimer = setTimeout(() => {
+        this._animationTimer = null;
+        this.markDirty();
+      }, 16);
+    }
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const anyAnimating = visible.some(m => {
const elapsed = now - m.createdAt;
const remaining = m.expireAt - now;
return elapsed < this._animationMs || remaining < this._animationMs;
});
if (anyAnimating) setTimeout(() => this.markDirty(), 16);
}
const anyAnimating = visible.some(m => {
const elapsed = now - m.createdAt;
const remaining = m.expireAt - now;
return elapsed < this._animationMs || remaining < this._animationMs;
});
if (anyAnimating && this._animationTimer === null) {
this._animationTimer = setTimeout(() => {
this._animationTimer = null;
this.markDirty();
}, 16);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` around lines 91 - 97, The timeout scheduling in the
animation check loop creates a new timer every render cycle when anyAnimating is
true, causing timer stacking and unnecessary markDirty() calls. Add a property
to track the pending animation timer ID (for example, _pendingAnimationTimerId),
and only schedule a new setTimeout call when no timer is already pending. When
the timer fires and calls markDirty(), reset the tracking property to null or
clear it so the next animation cycle can schedule a fresh timer if needed.

@pixeltannu

Copy link
Copy Markdown
Contributor Author

@Karanjot786 please mearge my issue

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

Labels

area:ui @termuijs/ui type:feature +10 pts. New feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature] Add smooth slide-in/fade-out animation for Toast notifications

1 participant