Fishing
import { useEffect, useId, useRef, useState } from 'react';
const SPOTS = [
{ label: 'Reeds', x: 86, y: 104, body: 'Perch and roach in the shallows, best at first light.' },
{ label: 'Deep', x: 130, y: 74, body: 'Bream on the bottom out here, so set the float deep.' },
{ label: 'Lilies', x: 164, y: 110, body: 'Tench under the pads. Fish it slowly in the evening.' },
{ label: 'Weir', x: 204, y: 84, body: 'Fast water by the weir: chub, and the odd trout.' },
];
const PIVOT = { x: 4, y: 128 };
const ROD = 80;
const REST = -56; // the rod's angle at rest, in degrees
const tipOf = (deg: number) => ({ x: PIVOT.x + ROD * Math.cos((deg * Math.PI) / 180), y: PIVOT.y + ROD * Math.sin((deg * Math.PI) / 180) });
const depth = (y: number) => 0.62 + ((y - 64) / 60) * 0.5; // further away is higher up and smaller
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
type Pose = { rod: number; fx: number; fy: number; sag: number; air: boolean };
const resting = (i: number): Pose => ({ rod: REST, fx: SPOTS[i].x, fy: SPOTS[i].y, sag: 14, air: false });
const css = `
.tabs-fishing { --shade: light-dark(#373a40, #121417); width: 230px; font-family: Futura, 'Trebuchet MS', 'Century Gothic', sans-serif; }
.tabs-fishing__pond { position: relative; height: 132px; }
.tabs-fishing__scene { position: absolute; left: 0; top: 0; overflow: visible; }
.tabs-fishing__trees { fill: light-dark(#ebedf0, #232529); }
.tabs-fishing__w1 { stop-color: light-dark(#ebedf0, #232529); }
.tabs-fishing__w2 { stop-color: light-dark(#f3f4f7, #1a1c1f); }
.tabs-fishing__glint { stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent); stroke-width: 1; stroke-linecap: round; }
.tabs-fishing__deep { fill: color-mix(in oklab, var(--shade) 35%, transparent); }
.tabs-fishing__reed { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 1.4; stroke-linecap: round; }
.tabs-fishing__pad { fill: light-dark(#cbced3, #393c41); }
.tabs-fishing__weir { fill: light-dark(#cbced3, #393c41); }
.tabs-fishing__foam { fill: none; stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 35%, transparent); stroke-width: 1; stroke-dasharray: 2 3; animation: tabs-fishing-foam 1.2s linear infinite; }
.tabs-fishing__rod { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; }
.tabs-fishing__grip { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 4.5; stroke-linecap: round; }
.tabs-fishing__reel { fill: light-dark(#50535b, #c6c8cd); stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; }
.tabs-fishing__line { fill: none; stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 55%, transparent); stroke-width: 0.7; }
.tabs-fishing__bob { animation: tabs-fishing-bob 2.6s ease-in-out infinite; }
.tabs-fishing__nibble { animation: tabs-fishing-nibble 7s ease-in-out 2s infinite; }
.tabs-fishing__float-top { fill: light-dark(#373a40, #f1f2f3); }
.tabs-fishing__float-body { fill: light-dark(#6d7179, #7d8087); }
.tabs-fishing__ring { fill: none; stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 40%, transparent); stroke-width: 1; opacity: 0; transform-box: fill-box; transform-origin: center; }
.tabs-fishing[data-run='a'] .tabs-fishing__ring { animation: tabs-fishing-ring-a 1.3s ease-out both; }
.tabs-fishing[data-run='b'] .tabs-fishing__ring { animation: tabs-fishing-ring-b 1.3s ease-out both; }
.tabs-fishing[data-run] .tabs-fishing__ring + .tabs-fishing__ring { animation-delay: 0.25s; }
.tabs-fishing__spot { position: absolute; padding: 2px 4px; border: 0; border-radius: 3px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 10.5px; letter-spacing: 0.06em; cursor: pointer; translate: -50% 0; transition: color 0.2s; }
.tabs-fishing__spot:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-fishing__spot[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-fishing__spot::after { content: ''; position: absolute; left: 50%; top: 100%; width: 1px; height: 6px; background: currentColor; opacity: 0.5; }
.tabs-fishing__spot:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 1px; }
.tabs-fishing__panel { min-height: 3em; margin-top: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
@keyframes tabs-fishing-bob { 50% { transform: translateY(1.2px); } }
@keyframes tabs-fishing-nibble { 0%, 84%, 100% { transform: none; } 86% { transform: translateY(2.5px); } 88% { transform: none; } 90% { transform: translateY(3px); } 93% { transform: none; } }
@keyframes tabs-fishing-ring-a { 0% { opacity: 0.9; transform: scale(0.2); } 100% { opacity: 0; transform: scale(1.6); } }
@keyframes tabs-fishing-ring-b { 0% { opacity: 0.9; transform: scale(0.2); } 100% { opacity: 0; transform: scale(1.6); } }
@keyframes tabs-fishing-foam { to { stroke-dashoffset: -10; } }
@media (prefers-reduced-motion: reduce) {
.tabs-fishing__bob, .tabs-fishing__nibble, .tabs-fishing__foam, .tabs-fishing[data-run] .tabs-fishing__ring { animation: none; }
}
`;
export function Preview() {
const id = useId();
const [{ sel, run }, setState] = useState({ sel: 2, run: '' });
const [pose, setPose] = useState<Pose>(() => resting(2));
const [landed, setLanded] = useState({ x: SPOTS[2].x, y: SPOTS[2].y, run: '' });
const live = useRef<Pose>(resting(2));
const raf = useRef(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const pick = (i: number) => setState((s) => (s.sel === i ? s : { sel: i, run: s.run === 'a' ? 'b' : 'a' }));
// Reel the float in to the rod tip, wind the rod back, whip it forward and send the float
// on an arc to the new spot, where it lands with rings and settles to bob.
useEffect(() => {
if (!run) return;
cancelAnimationFrame(raf.current);
const to = SPOTS[sel];
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
live.current = resting(sel);
setPose(live.current);
setLanded({ x: to.x, y: to.y, run: '' });
return;
}
const from = { ...live.current };
const t0 = performance.now();
const REEL = 420;
const WIND = 260;
const CAST = 640;
let landedYet = false;
const step = (now: number) => {
const t = now - t0;
const p: Pose = { ...from };
if (t < REEL) {
const k = ease(t / REEL);
const tip = tipOf(lerp(from.rod, REST - 12, k));
Object.assign(p, { rod: lerp(from.rod, REST - 12, k), fx: lerp(from.fx, tip.x, k), fy: lerp(from.fy, tip.y + 6, k * k), sag: lerp(from.sag, 0, k), air: k > 0.7 });
} else if (t < REEL + WIND) {
const k = ease((t - REEL) / WIND);
const rod = lerp(REST - 12, REST - 48, k);
const tip = tipOf(rod);
Object.assign(p, { rod, fx: tip.x - 4 * k, fy: tip.y + 6 + 4 * k, sag: 0, air: true });
} else if (t < REEL + WIND + CAST) {
const k = (t - REEL - WIND) / CAST;
// The rod whips past its rest angle and springs back.
const rod = REST + 26 * Math.exp(-6 * k) * Math.cos(11 * k + Math.PI) + 4;
const start = tipOf(REST - 48);
const lx = lerp(start.x, to.x, k);
const ly = lerp(start.y + 10, to.y, k) - 64 * Math.sin(Math.PI * k) * (1 - 0.35 * k);
Object.assign(p, { rod, fx: lx, fy: ly, sag: 2, air: true });
} else {
const k = Math.min(1, (t - REEL - WIND - CAST) / 420);
Object.assign(p, { rod: REST + 4 * (1 - ease(k)) * Math.cos(9 * k), fx: to.x, fy: to.y, sag: lerp(2, 14, ease(k)), air: false });
if (!landedYet) {
landedYet = true;
setLanded({ x: to.x, y: to.y, run });
}
}
live.current = p;
setPose(p);
if (t < REEL + WIND + CAST + 420) raf.current = requestAnimationFrame(step);
else {
live.current = resting(sel);
setPose(live.current);
}
};
raf.current = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf.current);
}, [sel, run]);
const n = SPOTS.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
pick(next);
tabs.current[next]?.focus();
};
const tip = tipOf(pose.rod);
const s = depth(pose.fy);
const line = `M${tip.x.toFixed(1)} ${tip.y.toFixed(1)}Q${((tip.x + pose.fx) / 2).toFixed(1)} ${(Math.max(tip.y, pose.fy) + pose.sag).toFixed(1)} ${pose.fx.toFixed(1)} ${(pose.fy - 12 * s).toFixed(1)}`;
const ls = depth(landed.y);
return (
<>
<style href="kit-tabs-fishing" precedence="kit">
{css}
</style>
<div className="tabs-fishing" data-run={landed.run || undefined}>
<div className="tabs-fishing__pond">
<svg className="tabs-fishing__scene" width="230" height="132" viewBox="0 0 230 132" aria-hidden="true">
<defs>
<linearGradient id={`${id}-water`} x1="0" x2="0" y1="0" y2="1">
<stop className="tabs-fishing__w1" offset="0" />
<stop className="tabs-fishing__w2" offset="1" />
</linearGradient>
</defs>
<path className="tabs-fishing__trees" d="M0 62V50Q8 40 16 48Q22 36 32 46Q40 38 48 48Q58 42 66 52Q80 46 92 54Q104 44 118 52Q128 40 140 50Q152 44 160 52Q172 42 184 50Q196 38 208 48Q218 42 230 50V62Z" />
<rect fill={`url(#${id}-water)`} x="0" y="60" width="230" height="72" />
<ellipse className="tabs-fishing__deep" cx="132" cy="76" rx="40" ry="7" />
{[
[20, 70, 40],
[150, 96, 30],
[96, 122, 44],
[182, 66, 22],
].map(([x, y, w]) => (
<line key={`${x}-${y}`} className="tabs-fishing__glint" x1={x} x2={x + w} y1={y} y2={y} />
))}
<path className="tabs-fishing__reed" d="M66 112Q64 96 60 88M70 112Q70 94 72 84M74 112Q76 98 80 92M62 112Q58 100 52 96" />
<ellipse className="tabs-fishing__pad" cx="180" cy="116" rx="10" ry="3.2" />
<ellipse className="tabs-fishing__pad" cx="150" cy="120" rx="8" ry="2.6" />
<ellipse className="tabs-fishing__pad" cx="190" cy="106" rx="6" ry="2" />
<rect className="tabs-fishing__weir" x="186" y="78" width="44" height="3" rx="1" />
<path className="tabs-fishing__foam" d="M188 83H230M190 86H230" />
<ellipse className="tabs-fishing__ring" cx={landed.x} cy={landed.y + 1} rx={9 * ls} ry={2.6 * ls} />
<ellipse className="tabs-fishing__ring" cx={landed.x} cy={landed.y + 1} rx={9 * ls} ry={2.6 * ls} />
<path className="tabs-fishing__line" d={line} />
<g transform={`translate(${pose.fx.toFixed(1)} ${pose.fy.toFixed(1)}) scale(${(s * 1.6).toFixed(3)})`}>
<g className={pose.air ? undefined : 'tabs-fishing__bob'}>
<g className={pose.air ? undefined : 'tabs-fishing__nibble'}>
<path className="tabs-fishing__float-body" d="M0 3C-2.4 1-2.4-2 0-3C2.4-2 2.4 1 0 3Z" />
<path className="tabs-fishing__float-top" d="M-0.9-2.6H0.9V-7.5Q0-8.6-0.9-7.5Z" />
</g>
</g>
</g>
<path className="tabs-fishing__rod" d={`M${PIVOT.x} ${PIVOT.y}L${tip.x.toFixed(1)} ${tip.y.toFixed(1)}`} />
<path className="tabs-fishing__grip" d={`M${PIVOT.x} ${PIVOT.y}L${(PIVOT.x + 0.26 * (tip.x - PIVOT.x)).toFixed(1)} ${(PIVOT.y + 0.26 * (tip.y - PIVOT.y)).toFixed(1)}`} />
<circle className="tabs-fishing__reel" cx={PIVOT.x + 10 * Math.cos((pose.rod * Math.PI) / 180)} cy={PIVOT.y + 10 * Math.sin((pose.rod * Math.PI) / 180) + 3} r="3.5" />
</svg>
<div role="tablist" aria-label="Spots" onKeyDown={onKeyDown}>
{SPOTS.map((sp, i) => (
<button
key={sp.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-fishing__spot"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
style={{ left: sp.x, top: sp.y - 30 }}
onClick={() => pick(i)}
>
{sp.label}
</button>
))}
</div>
</div>
{SPOTS.map((sp, i) => (
<div key={sp.label} className="tabs-fishing__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{sp.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsFishing(el) on the mounted element instead.
<div class="tabs-fishing">
<div class="tabs-fishing__pond">
<svg class="tabs-fishing__scene" width="230" height="132" viewBox="0 0 230 132" aria-hidden="true">
<defs>
<linearGradient id="tabs-fishing-water" x1="0" x2="0" y1="0" y2="1">
<stop class="tabs-fishing__w1" offset="0"></stop>
<stop class="tabs-fishing__w2" offset="1"></stop>
</linearGradient>
</defs>
<path class="tabs-fishing__trees" d="M0 62V50Q8 40 16 48Q22 36 32 46Q40 38 48 48Q58 42 66 52Q80 46 92 54Q104 44 118 52Q128 40 140 50Q152 44 160 52Q172 42 184 50Q196 38 208 48Q218 42 230 50V62Z"></path>
<rect fill="url(#tabs-fishing-water)" x="0" y="60" width="230" height="72"></rect>
<ellipse class="tabs-fishing__deep" cx="132" cy="76" rx="40" ry="7"></ellipse>
<line class="tabs-fishing__glint" x1="20" x2="60" y1="70" y2="70"></line>
<line class="tabs-fishing__glint" x1="150" x2="180" y1="96" y2="96"></line>
<line class="tabs-fishing__glint" x1="96" x2="140" y1="122" y2="122"></line>
<line class="tabs-fishing__glint" x1="182" x2="204" y1="66" y2="66"></line>
<path class="tabs-fishing__reed" d="M66 112Q64 96 60 88M70 112Q70 94 72 84M74 112Q76 98 80 92M62 112Q58 100 52 96"></path>
<ellipse class="tabs-fishing__pad" cx="180" cy="116" rx="10" ry="3.2"></ellipse>
<ellipse class="tabs-fishing__pad" cx="150" cy="120" rx="8" ry="2.6"></ellipse>
<ellipse class="tabs-fishing__pad" cx="190" cy="106" rx="6" ry="2"></ellipse>
<rect class="tabs-fishing__weir" x="186" y="78" width="44" height="3" rx="1"></rect>
<path class="tabs-fishing__foam" d="M188 83H230M190 86H230"></path>
<ellipse class="tabs-fishing__ring" cx="164" cy="111" rx="9.030000000000001" ry="2.608666666666667"></ellipse>
<ellipse class="tabs-fishing__ring" cx="164" cy="111" rx="9.030000000000001" ry="2.608666666666667"></ellipse>
<path class="tabs-fishing__line" d="M48.7 61.7Q106.4 124.0 164.0 98.0"></path>
<g transform="translate(164.0 110.0) scale(1.605)">
<g class="tabs-fishing__bob">
<g class="tabs-fishing__nibble">
<path class="tabs-fishing__float-body" d="M0 3C-2.4 1-2.4-2 0-3C2.4-2 2.4 1 0 3Z"></path>
<path class="tabs-fishing__float-top" d="M-0.9-2.6H0.9V-7.5Q0-8.6-0.9-7.5Z"></path>
</g>
</g>
</g>
<path class="tabs-fishing__rod" d="M4 128L48.7 61.7"></path>
<path class="tabs-fishing__grip" d="M4 128L15.6 110.8"></path>
<circle class="tabs-fishing__reel" cx="9.591929034707467" cy="122.70962427444958" r="3.5"></circle>
</svg>
<div role="tablist" aria-label="Spots">
<button class="tabs-fishing__spot" type="button" role="tab" id="tabs-fishing-tab-0" aria-selected="false" aria-controls="tabs-fishing-panel-0" tabindex="-1" style="left:86px;top:74px">Reeds</button>
<button class="tabs-fishing__spot" type="button" role="tab" id="tabs-fishing-tab-1" aria-selected="false" aria-controls="tabs-fishing-panel-1" tabindex="-1" style="left:130px;top:44px">Deep</button>
<button class="tabs-fishing__spot" type="button" role="tab" id="tabs-fishing-tab-2" aria-selected="true" aria-controls="tabs-fishing-panel-2" tabindex="0" style="left:164px;top:80px">Lilies</button>
<button class="tabs-fishing__spot" type="button" role="tab" id="tabs-fishing-tab-3" aria-selected="false" aria-controls="tabs-fishing-panel-3" tabindex="-1" style="left:204px;top:54px">Weir</button>
</div>
</div>
<div class="tabs-fishing__panel" role="tabpanel" id="tabs-fishing-panel-0" aria-labelledby="tabs-fishing-tab-0" hidden="">Perch and roach in the shallows, best at first light.</div>
<div class="tabs-fishing__panel" role="tabpanel" id="tabs-fishing-panel-1" aria-labelledby="tabs-fishing-tab-1" hidden="">Bream on the bottom out here, so set the float deep.</div>
<div class="tabs-fishing__panel" role="tabpanel" id="tabs-fishing-panel-2" aria-labelledby="tabs-fishing-tab-2">Tench under the pads. Fish it slowly in the evening.</div>
<div class="tabs-fishing__panel" role="tabpanel" id="tabs-fishing-panel-3" aria-labelledby="tabs-fishing-tab-3" hidden="">Fast water by the weir: chub, and the odd trout.</div>
</div>.tabs-fishing { --shade: light-dark(#373a40, #121417); width: 230px; font-family: Futura, 'Trebuchet MS', 'Century Gothic', sans-serif; }
.tabs-fishing__pond { position: relative; height: 132px; }
.tabs-fishing__scene { position: absolute; left: 0; top: 0; overflow: visible; }
.tabs-fishing__trees { fill: light-dark(#ebedf0, #232529); }
.tabs-fishing__w1 { stop-color: light-dark(#ebedf0, #232529); }
.tabs-fishing__w2 { stop-color: light-dark(#f3f4f7, #1a1c1f); }
.tabs-fishing__glint { stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent); stroke-width: 1; stroke-linecap: round; }
.tabs-fishing__deep { fill: color-mix(in oklab, var(--shade) 35%, transparent); }
.tabs-fishing__reed { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 1.4; stroke-linecap: round; }
.tabs-fishing__pad { fill: light-dark(#cbced3, #393c41); }
.tabs-fishing__weir { fill: light-dark(#cbced3, #393c41); }
.tabs-fishing__foam { fill: none; stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 35%, transparent); stroke-width: 1; stroke-dasharray: 2 3; animation: tabs-fishing-foam 1.2s linear infinite; }
.tabs-fishing__rod { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; }
.tabs-fishing__grip { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 4.5; stroke-linecap: round; }
.tabs-fishing__reel { fill: light-dark(#50535b, #c6c8cd); stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; }
.tabs-fishing__line { fill: none; stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 55%, transparent); stroke-width: 0.7; }
.tabs-fishing__bob { animation: tabs-fishing-bob 2.6s ease-in-out infinite; }
.tabs-fishing__nibble { animation: tabs-fishing-nibble 7s ease-in-out 2s infinite; }
.tabs-fishing__float-top { fill: light-dark(#373a40, #f1f2f3); }
.tabs-fishing__float-body { fill: light-dark(#6d7179, #7d8087); }
.tabs-fishing__ring { fill: none; stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 40%, transparent); stroke-width: 1; opacity: 0; transform-box: fill-box; transform-origin: center; }
.tabs-fishing[data-run='a'] .tabs-fishing__ring { animation: tabs-fishing-ring-a 1.3s ease-out both; }
.tabs-fishing[data-run='b'] .tabs-fishing__ring { animation: tabs-fishing-ring-b 1.3s ease-out both; }
.tabs-fishing[data-run] .tabs-fishing__ring + .tabs-fishing__ring { animation-delay: 0.25s; }
.tabs-fishing__spot { position: absolute; padding: 2px 4px; border: 0; border-radius: 3px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 10.5px; letter-spacing: 0.06em; cursor: pointer; translate: -50% 0; transition: color 0.2s; }
.tabs-fishing__spot:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-fishing__spot[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-fishing__spot::after { content: ''; position: absolute; left: 50%; top: 100%; width: 1px; height: 6px; background: currentColor; opacity: 0.5; }
.tabs-fishing__spot:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 1px; }
.tabs-fishing__panel { min-height: 3em; margin-top: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
@keyframes tabs-fishing-bob { 50% { transform: translateY(1.2px); } }
@keyframes tabs-fishing-nibble { 0%, 84%, 100% { transform: none; } 86% { transform: translateY(2.5px); } 88% { transform: none; } 90% { transform: translateY(3px); } 93% { transform: none; } }
@keyframes tabs-fishing-ring-a { 0% { opacity: 0.9; transform: scale(0.2); } 100% { opacity: 0; transform: scale(1.6); } }
@keyframes tabs-fishing-ring-b { 0% { opacity: 0.9; transform: scale(0.2); } 100% { opacity: 0; transform: scale(1.6); } }
@keyframes tabs-fishing-foam { to { stroke-dashoffset: -10; } }
@media (prefers-reduced-motion: reduce) {
.tabs-fishing__bob, .tabs-fishing__nibble, .tabs-fishing__foam, .tabs-fishing[data-run] .tabs-fishing__ring { animation: none; }
}function initTabsFishing(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var line = root.querySelector('.tabs-fishing__line');
var bob = root.querySelector('.tabs-fishing__bob');
var nibble = root.querySelector('.tabs-fishing__nibble');
var floatGroup = bob.parentNode;
var rod = root.querySelector('.tabs-fishing__rod');
var grip = root.querySelector('.tabs-fishing__grip');
var reel = root.querySelector('.tabs-fishing__reel');
var rings = root.querySelectorAll('.tabs-fishing__ring');
var SPOTS = [{ x: 86, y: 104 }, { x: 130, y: 74 }, { x: 164, y: 110 }, { x: 204, y: 84 }];
var PIVOT = { x: 4, y: 128 };
var ROD = 80;
var REST = -56; // the rod's angle at rest, in degrees
var REEL = 420;
var WIND = 260;
var CAST = 640;
var run = 'a';
var raf = 0;
function tipOf(deg) { return { x: PIVOT.x + ROD * Math.cos((deg * Math.PI) / 180), y: PIVOT.y + ROD * Math.sin((deg * Math.PI) / 180) }; }
function depth(y) { return 0.62 + ((y - 64) / 60) * 0.5; } // further away is higher up and smaller
function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function lerp(a, b, t) { return a + (b - a) * t; }
function resting(i) { return { rod: REST, fx: SPOTS[i].x, fy: SPOTS[i].y, sag: 14, air: false }; }
var pose = resting(sel);
function draw() {
var tip = tipOf(pose.rod);
var s = depth(pose.fy);
line.setAttribute('d', 'M' + tip.x.toFixed(1) + ' ' + tip.y.toFixed(1) + 'Q' + ((tip.x + pose.fx) / 2).toFixed(1) + ' ' + (Math.max(tip.y, pose.fy) + pose.sag).toFixed(1) + ' ' + pose.fx.toFixed(1) + ' ' + (pose.fy - 12 * s).toFixed(1));
floatGroup.setAttribute('transform', 'translate(' + pose.fx.toFixed(1) + ' ' + pose.fy.toFixed(1) + ') scale(' + (s * 1.6).toFixed(3) + ')');
// No bobbing in the air: these classes carry the idle animations.
bob.classList.toggle('tabs-fishing__bob', !pose.air);
nibble.classList.toggle('tabs-fishing__nibble', !pose.air);
rod.setAttribute('d', 'M' + PIVOT.x + ' ' + PIVOT.y + 'L' + tip.x.toFixed(1) + ' ' + tip.y.toFixed(1));
grip.setAttribute('d', 'M' + PIVOT.x + ' ' + PIVOT.y + 'L' + (PIVOT.x + 0.26 * (tip.x - PIVOT.x)).toFixed(1) + ' ' + (PIVOT.y + 0.26 * (tip.y - PIVOT.y)).toFixed(1));
reel.setAttribute('cx', PIVOT.x + 10 * Math.cos((pose.rod * Math.PI) / 180));
reel.setAttribute('cy', PIVOT.y + 10 * Math.sin((pose.rod * Math.PI) / 180) + 3);
}
function land(to) {
var s = depth(to.y);
rings.forEach(function (ring) {
ring.setAttribute('cx', to.x);
ring.setAttribute('cy', to.y + 1);
ring.setAttribute('rx', 9 * s);
ring.setAttribute('ry', 2.6 * s);
});
run = run === 'a' ? 'b' : 'a';
root.setAttribute('data-run', run); // replays the rings
}
// Reel the float in to the rod tip, wind the rod back, whip it forward and send the float
// on an arc to the new spot, where it lands with rings and settles to bob.
function update() {
cancelAnimationFrame(raf);
var to = SPOTS[sel];
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
pose = resting(sel);
draw();
return;
}
var from = pose;
var t0 = performance.now();
var landed = false;
function step(now) {
var t = now - t0;
var k;
var rodAngle;
if (t < REEL) {
k = ease(t / REEL);
rodAngle = lerp(from.rod, REST - 12, k);
var reelTip = tipOf(rodAngle);
pose = { rod: rodAngle, fx: lerp(from.fx, reelTip.x, k), fy: lerp(from.fy, reelTip.y + 6, k * k), sag: lerp(from.sag, 0, k), air: k > 0.7 };
} else if (t < REEL + WIND) {
k = ease((t - REEL) / WIND);
rodAngle = lerp(REST - 12, REST - 48, k);
var windTip = tipOf(rodAngle);
pose = { rod: rodAngle, fx: windTip.x - 4 * k, fy: windTip.y + 6 + 4 * k, sag: 0, air: true };
} else if (t < REEL + WIND + CAST) {
k = (t - REEL - WIND) / CAST;
// The rod whips past its rest angle and springs back.
var start = tipOf(REST - 48);
pose = {
rod: REST + 26 * Math.exp(-6 * k) * Math.cos(11 * k + Math.PI) + 4,
fx: lerp(start.x, to.x, k),
fy: lerp(start.y + 10, to.y, k) - 64 * Math.sin(Math.PI * k) * (1 - 0.35 * k),
sag: 2,
air: true,
};
} else {
k = Math.min(1, (t - REEL - WIND - CAST) / 420);
pose = { rod: REST + 4 * (1 - ease(k)) * Math.cos(9 * k), fx: to.x, fy: to.y, sag: lerp(2, 14, ease(k)), air: false };
if (!landed) {
landed = true;
land(to);
}
}
draw();
if (t < REEL + WIND + CAST + 420) raf = requestAnimationFrame(step);
else {
pose = resting(sel);
draw();
}
}
raf = requestAnimationFrame(step);
}
function pick(i) {
if (i === sel) return;
var from = sel;
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === sel));
tab.tabIndex = k === sel ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.hidden = k !== sel; });
update(from);
}
function onClick(e) {
var i = tabs.indexOf(e.currentTarget);
if (i >= 0) pick(i);
}
function onKey(e) {
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
pick(next);
tabs[next].focus();
}
tabs.forEach(function (tab) { tab.addEventListener('click', onClick); });
list.addEventListener('keydown', onKey);
return function () {
tabs.forEach(function (tab) { tab.removeEventListener('click', onClick); });
list.removeEventListener('keydown', onKey);
cancelAnimationFrame(raf);
};
}
document.querySelectorAll('.tabs-fishing').forEach(initTabsFishing);Goal: Create a "Fishing" tab.
Visual behavior: Fishing spots on a pond: the float is reeled in, the rod whips and the float flies to the spot you pick, lands with ripples and bobs, nibbling now and then.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- Four stages on one clock: reel in along the water, wind the rod back, whip it forward with a decaying swing while the float flies a parabola, then land, as the slack line sags back in.
- The line is one quadratic curve from the rod tip to the float, redrawn every frame with its sag, so it stays attached through all of it.
- Rings replay on landing, and the float bobs at rest with a nibble every so often. Further spots sit higher and smaller, so the pond has depth.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. The drawing is aria-hidden. Under prefers-reduced-motion the float is simply at the new spot, without bobbing or nibbling.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Fishing" tab.
Visual behavior: Fishing spots on a pond: the float is reeled in, the rod whips and the float flies to the spot you pick, lands with ripples and bobs, nibbling now and then.
Technique:
- A real tablist of buttons with roving tabindex and a labelled panel for each tab; everything drawn follows the selection.
- Four stages on one clock: reel in along the water, wind the rod back, whip it forward with a decaying swing while the float flies a parabola, then land, as the slack line sags back in.
- The line is one quadratic curve from the rod tip to the float, redrawn every frame with its sag, so it stays attached through all of it.
- Rings replay on landing, and the float bobs at rest with a nibble every so often. Further spots sit higher and smaller, so the pond has depth.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. The drawing is aria-hidden. Under prefers-reduced-motion the float is simply at the new spot, without bobbing or nibbling.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Fishing spots on a pond: the float is reeled in, the rod whips and the float flies to the spot you pick, lands with ripples and bobs, nibbling now and then.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, spring, loop
- Techniques
- raf, svg, keyframes, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. The drawing is aria-hidden. Under prefers-reduced-motion the float is simply at the new spot, without bobbing or nibbling.