Skip to content

Instantly share code, notes, and snippets.

@alessaba
Last active September 22, 2026 11:19
Show Gist options
  • Select an option

  • Save alessaba/5f6c196eaee3bfa9ae2fcbab6febf234 to your computer and use it in GitHub Desktop.

Select an option

Save alessaba/5f6c196eaee3bfa9ae2fcbab6febf234 to your computer and use it in GitHub Desktop.
Ghostty cursor shader that changes color based on direction (typing/deleting) with trail and few sparkles. Good for daily driver
// ─────────────────────────────────────────────────────────────────────────────
// cursor_mood.glsl — a cursor that wears its mood on its sleeve.
//
// moving right (typing) → faint blue halo + sparkles, teal comet behind
// moving left (deleting) → faint red halo + sparkles + comet
// idle / vertical motion → calm: the theme's own cursor colour (back to
// white ~0.2 s after the last change)
//
//
// Direction is read from iPreviousCursor vs iCurrentCursor.
// The comet is the elegant generalisation of ripple_cursor.glsl's
// "circle at the previous cursor" and the halo follows
// cursor_blaze.glsl's soft-box glow, plus some sparkles
//
// Glow is pure additive; the glyph itself gets a subtle shear that leans it
// into the direction of travel (WARP_PX).
// Chains fine with other custom shaders through iChannel0.
//
// Enable with: custom-shader = "~/.config/ghostty/ale_cursor_mood.glsl"
// ─────────────────────────────────────────────────────────────────────────────
// Orientation of "up" inside fragCoord.
// Metal (macOS, default renderer): fragCoord +Y points DOWN → up is (0, -1)
// OpenGL renderer: fragCoord +Y points UP → up is (0, +1)
const vec2 UP = vec2(0.0, -1.0);
// ── palette ──────────────────────────────────────────────────────────────────
const vec3 WRITE_BLUE = vec3(0.42, 0.66, 1.00); // sparkles: ink you just laid down
const vec3 WRITE_TEAL = vec3(0.28, 0.78, 0.80); // comet: the wake it left
const vec3 ERASE_RED = vec3(1.00, 0.34, 0.28);
// ── tuning (all times in seconds, lengths relative to the cursor height) ─────
const float MOOD_FADE = 0.20; // colour resets to calm this long after a change
const float MOOD_HOLD = 0.25; // how long the change "pop" lasts
const float TRAIL_HOLD = 0.22; // comet fade
const float TRAIL_LEN = 2.1; // comet length, in cursor heights
const float WARP_PX = 7; // glyph lean across its height, in pixels
const float SPARK_LIFE = 0.70; // sparkle lifetime
const float SPARK_LAG = 0.14; // stagger between sparkles (a spray, not a pop)
const int SPARKS = 9;
// ── helpers ──────────────────────────────────────────────────────────────────
vec2 hash22(float n) {
vec3 p = fract(vec3(n) * vec3(0.1031, 0.1030, 0.0973));
p += dot(p, p.yzx + 33.33);
return fract((p.xx + p.yz) * p.zy);
}
float sdRoundBox(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
// A four-point twinkle: bright core plus soft cross rays.
float sparkShape(vec2 p, float r) {
float core = smoothstep(r, 0.0, length(p));
float rayX = smoothstep(r * 3.0, 0.0, abs(p.x)) * smoothstep(r * 0.8, 0.0, abs(p.y));
float rayY = smoothstep(r * 3.0, 0.0, abs(p.y)) * smoothstep(r * 0.8, 0.0, abs(p.x));
return core + 0.55 * max(rayX, rayY);
}
// iCurrentCursor is (x, y, w, h) where (x, y) is the rect's min-x / max-y
// corner in fragCoord space and the size grows +x / −y (see updateCustomShader
// Uniforms in src/renderer/generic.zig). If your glow ever sits one cursor
// height off, flip the sign here once and it's fixed.
vec2 cursorCenter(vec4 c) { return c.xy + 0.5 * c.zw * vec2(1.0, -1.0); }
// ── main ─────────────────────────────────────────────────────────────────────
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
float visible = float(iCursorVisible);
vec2 center = cursorCenter(iCurrentCursor);
vec2 half_ = 0.5 * iCurrentCursor.zw;
float H = iCurrentCursor.w; // ≈ one cell height
vec2 q = fragCoord - center;
// The last step the cursor took, and how long ago it happened.
vec2 move = center - cursorCenter(iPreviousCursor);
if (iPreviousCursor.z <= 0.0) move = vec2(0.0); // no history yet
move = clamp(move, vec2(-4.0 * H), vec2(4.0 * H));
float age = max(iTime - iTimeCursorChange, 0.0);
float speed = length(move);
// +1 writing (→), −1 deleting (←), 0 when calm.
float mood = smoothstep(1.5, 6.0, abs(move.x)) * sign(move.x);
float energy = exp(-age / MOOD_HOLD) * visible; // fresh change → 1
float moodAge = (1.0 - smoothstep(0.0, MOOD_FADE, age)) * visible; // colour reset
float fade = exp(-age / TRAIL_HOLD) * visible;
vec2 u = speed > 0.5 ? normalize(move) : vec2(1.0, 0.0);
// ── glyph warp ───────────────────────────────────────────────────────────
// The glyph is baked into iChannel0, so shearing the sample coordinate
// leans it into the direction of travel — momentum, up to WARP_PX across
// the glyph. The shear is symmetric about the centre, so the glyph never
// slides off its cell; it just slants and settles back with the "pop".
// (Flip the sign of `t` if you'd rather it drag behind like smoke.)
vec2 disp = vec2(0.0);
if (speed > 0.5 && abs(mood) > 0.05) {
float d0 = sdRoundBox(q, half_, 0.20 * H);
float w = exp(-max(d0, 0.0) / (0.80 * H)); // near the glyph only
float t = 0.5 * clamp(dot(q, UP) / (0.60 * H), -1.0, 1.0); // + = top
disp = u * (WARP_PX * energy) * abs(mood) * w * t;
}
vec4 screen = texture2D(iChannel0, clamp((fragCoord - disp) / iResolution.xy, 0.0, 1.0));
// Mood colour: the theme's own cursor colour when calm, blue or red while
// the cursor travels — and it falls back to calm on its own.
vec3 calm = iCurrentCursorColor.rgb;
vec3 hot = mix(ERASE_RED, WRITE_BLUE, 0.5 + 0.5 * mood);
vec3 hotTrail = mix(ERASE_RED, WRITE_TEAL, 0.5 + 0.5 * mood);
vec3 tint = mix(calm, hot, abs(mood) * moodAge);
vec3 glow = vec3(0.0);
// ── halo ────────────────────────────────────────────────────────────────
// Soft box around the cursor (any cursor style), leaning into the direction
// of travel so it has a bit of body language.
vec2 qa = q;
qa.x /= 1.0 + 0.30 * energy * abs(mood);
float d = sdRoundBox(qa, half_, 0.30 * H);
float halo = exp(-max(d, 0.0) / (0.22 * H)); // wide breath
halo += 0.5 * exp(-max(d, 0.0) / (0.06 * H)); // tight core
glow += tint * halo * (0.25 + 0.50 * energy);
// ── comet ───────────────────────────────────────────────────────────────
// A subtle smear trailing behind the motion — where the cursor just was.
if (speed > 0.5 && abs(mood) > 0.05) {
float tLen = TRAIL_LEN * H * clamp(0.5 + speed / 16.0, 0.75, 2.0);
vec2 ta = center - u * (0.25 * H);
vec2 tb = center - u * tLen;
vec2 ba = tb - ta;
float s = clamp(dot(fragCoord - ta, ba) / max(dot(ba, ba), 1e-4), 0.0, 1.0);
float dT = length((fragCoord - ta) - ba * s);
float w = mix(0.45, 0.08, s) * H; // tapers to a point
float comet = smoothstep(w, w * 0.15, dT) * (1.0 - s) * (1.0 - s);
glow += hotTrail * comet * 0.16 * fade * abs(mood);
// ghost of the previous cursor cell, the ripple_cursor nod
float ghost = exp(-max(sdRoundBox(fragCoord - cursorCenter(iPreviousCursor),
0.5 * iPreviousCursor.zw, 0.30 * H), 0.0) / (0.15 * H));
glow += hotTrail * ghost * 0.05 * fade * abs(mood);
}
// ── sparkles ────────────────────────────────────────────────────────────
// One little spray per cursor change; the seed is the change timestamp so
// every keystroke gets its own constellation.
float seed = floor(iTimeCursorChange * 1000.0);
for (int i = 0; i < SPARKS; i++) {
float fi = float(i);
vec2 r1 = hash22(seed * 0.131 + fi * 7.31 + 1.7);
vec2 r2 = hash22(seed * 0.457 + fi * 3.19 + 9.2);
float t = age - SPARK_LAG * r1.x; // staggered birth
if (t <= 0.0 || t >= SPARK_LIFE) continue;
vec2 birth = center + vec2(r1.x - 0.5, r1.y - 0.5) * vec2(0.6, 1.2) * H;
vec2 v = UP * (70.0 + 110.0 * r2.x) // rise
- u * (25.0 + 55.0 * r2.y) * abs(mood) // drift back
+ vec2(r1.y - 0.5, 0.0) * 55.0; // scatter
vec2 pos = birth + v * t - UP * 0.5 * 45.0 * t * t; // ... and settle
float life = 1.0 - t / SPARK_LIFE;
float twinkle = 0.6 + 0.4 * sin(t * 42.0 + fi * 2.4);
float spark = sparkShape(fragCoord - pos, (0.07 + 0.05 * r2.y) * H);
glow += hot * spark * life * life * twinkle * (0.09 + 0.13 * abs(mood));
}
// Soft knee: near-linear where it matters, saturating before anything can
// blow out into HDR territory. This is the "all day" guarantee.
glow = glow / (1.0 + glow);
fragColor = vec4(screen.rgb + glow * visible, 1.0);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment