Last active
September 22, 2026 11:19
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ───────────────────────────────────────────────────────────────────────────── | |
| // 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