Dear team, please review.Hey all, got a sec to look?Yo crew, peep this!
Tide
React + Tailwind
import { useId } from 'react';
const css = `
@keyframes radio-tide-swell { to { transform: translateX(-40px); } }
@keyframes radio-tide-bob { 0%, 100% { transform: translateY(0) rotate(-6deg); } 50% { transform: translateY(2px) rotate(6deg); } }
.radio-tide { --radio-tide-y: 70px; display: grid; justify-items: center; gap: 12px; }
.radio-tide:has(.radio-tide__input[value='mid']:checked) { --radio-tide-y: 48px; }
.radio-tide:has(.radio-tide__input[value='high']:checked) { --radio-tide-y: 24px; }
.radio-tide__scene {
position: relative;
width: 210px;
height: 100px;
overflow: hidden;
border-radius: 10px;
background: linear-gradient(light-dark(#f3f4f7, #1a1c1f), light-dark(#ebedf0, #232529));
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.radio-tide__pier { position: absolute; left: 30px; top: 6px; bottom: 0; width: 10px; background: linear-gradient(90deg, light-dark(#cbced3, #393c41), light-dark(#dbdde1, #2e3035)); }
.radio-tide__deck { position: absolute; left: 0; top: 6px; width: 54px; height: 5px; background: light-dark(#6d7179, #7d8087); }
.radio-tide__mark { position: absolute; left: 44px; width: 18px; height: 1px; background: light-dark(#6d7179, #7d8087); }
.radio-tide__mark span { position: absolute; left: 22px; top: -5px; color: light-dark(#5f626a, #a7abb2); font-size: 8px; font-weight: 600; letter-spacing: 0.08em; }
.radio-tide__sea {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 110px;
transform: translateY(var(--radio-tide-y));
transition: transform 1.4s cubic-bezier(0.45, 0, 0.55, 1);
}
.radio-tide__water { position: absolute; left: 0; right: 0; top: 4px; bottom: 0; background: linear-gradient(color-mix(in oklab, light-dark(#6d7179, #7d8087) 75%, transparent), color-mix(in oklab, light-dark(#dbdde1, #2e3035) 85%, transparent)); }
.radio-tide__wave { position: absolute; left: 0; top: 0; width: 260px; height: 8px; animation: radio-tide-swell 2.6s linear infinite; }
.radio-tide__wave path { fill: color-mix(in oklab, light-dark(#6d7179, #7d8087) 75%, transparent); stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.2; }
.radio-tide__buoy { position: absolute; left: 140px; top: -10px; width: 12px; height: 16px; animation: radio-tide-bob 2.2s ease-in-out infinite; }
.radio-tide__buoy::before { content: ''; position: absolute; left: 0; bottom: 0; width: 12px; height: 9px; border-radius: 6px 6px 3px 3px; background: linear-gradient(light-dark(#373a40, #f1f2f3) 50%, light-dark(#5f626a, #a7abb2) 50%); }
.radio-tide__buoy::after { content: ''; position: absolute; left: 5px; top: 0; width: 2px; height: 8px; background: light-dark(#50535b, #c6c8cd); }
.radio-tide__opts { display: flex; gap: 4px; padding: 4px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.radio-tide__opt {
position: relative;
padding: 5px 12px;
border-radius: 7px;
color: light-dark(#5f626a, #a7abb2);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: color 0.2s, background-color 0.2s;
}
.radio-tide__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-tide__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.radio-tide__opt:has(.radio-tide__input:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.radio-tide__opt:has(.radio-tide__input:checked) { background: light-dark(#42454c, #e5e6e9); color: light-dark(#fbfcfd, #121417); }
@media (prefers-reduced-motion: reduce) {
.radio-tide * { animation: none !important; transition-duration: 0.01s !important; }
}
`;
const LEVELS = [
{ value: 'low', label: 'Low', y: 74 },
{ value: 'mid', label: 'Mid', y: 52 },
{ value: 'high', label: 'High', y: 28 },
];
export function TideRadio() {
const name = useId();
return (
<>
<style>
{css}
</style>
<div className="radio-tide">
<span className="radio-tide__scene" aria-hidden="true">
<span className="radio-tide__pier" />
<span className="radio-tide__deck" />
{LEVELS.map((l) => (
<span key={l.value} className="radio-tide__mark" style={{ top: l.y }}>
<span>{l.label.toUpperCase()}</span>
</span>
))}
<span className="radio-tide__sea">
<span className="radio-tide__water" />
<svg className="radio-tide__wave" viewBox="0 0 260 8" preserveAspectRatio="none">
<path d="M0 4Q10 0 20 4T40 4T60 4T80 4T100 4T120 4T140 4T160 4T180 4T200 4T220 4T240 4T260 4V8H0Z" />
</svg>
<span className="radio-tide__buoy" />
</span>
</span>
<div className="radio-tide__opts" role="radiogroup" aria-label="Tide">
{LEVELS.map((l, i) => (
<label key={l.value} className="radio-tide__opt">
<input className="radio-tide__input" type="radio" name={name} value={l.value} defaultChecked={i === 0} />
{l.label}
</label>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="radio-tide">
<span class="radio-tide__scene" aria-hidden="true">
<span class="radio-tide__pier"></span>
<span class="radio-tide__deck"></span>
<span class="radio-tide__mark" style="top:74px">
<span>LOW</span>
</span>
<span class="radio-tide__mark" style="top:52px">
<span>MID</span>
</span>
<span class="radio-tide__mark" style="top:28px">
<span>HIGH</span>
</span>
<span class="radio-tide__sea">
<span class="radio-tide__water"></span>
<svg class="radio-tide__wave" viewBox="0 0 260 8" preserveAspectRatio="none">
<path d="M0 4Q10 0 20 4T40 4T60 4T80 4T100 4T120 4T140 4T160 4T180 4T200 4T220 4T240 4T260 4V8H0Z"></path>
</svg>
<span class="radio-tide__buoy"></span>
</span>
</span>
<div class="radio-tide__opts" role="radiogroup" aria-label="Tide">
<label class="radio-tide__opt"><input class="radio-tide__input" type="radio" name="radio-tide" checked="" value="low"/>Low</label>
<label class="radio-tide__opt"><input class="radio-tide__input" type="radio" name="radio-tide" value="mid"/>Mid</label>
<label class="radio-tide__opt"><input class="radio-tide__input" type="radio" name="radio-tide" value="high"/>High</label>
</div>
</div>CSS
@keyframes radio-tide-swell { to { transform: translateX(-40px); } }
@keyframes radio-tide-bob { 0%, 100% { transform: translateY(0) rotate(-6deg); } 50% { transform: translateY(2px) rotate(6deg); } }
.radio-tide { --radio-tide-y: 70px; display: grid; justify-items: center; gap: 12px; }
.radio-tide:has(.radio-tide__input[value='mid']:checked) { --radio-tide-y: 48px; }
.radio-tide:has(.radio-tide__input[value='high']:checked) { --radio-tide-y: 24px; }
.radio-tide__scene {
position: relative;
width: 210px;
height: 100px;
overflow: hidden;
border-radius: 10px;
background: linear-gradient(light-dark(#f3f4f7, #1a1c1f), light-dark(#ebedf0, #232529));
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.radio-tide__pier { position: absolute; left: 30px; top: 6px; bottom: 0; width: 10px; background: linear-gradient(90deg, light-dark(#cbced3, #393c41), light-dark(#dbdde1, #2e3035)); }
.radio-tide__deck { position: absolute; left: 0; top: 6px; width: 54px; height: 5px; background: light-dark(#6d7179, #7d8087); }
.radio-tide__mark { position: absolute; left: 44px; width: 18px; height: 1px; background: light-dark(#6d7179, #7d8087); }
.radio-tide__mark span { position: absolute; left: 22px; top: -5px; color: light-dark(#5f626a, #a7abb2); font-size: 8px; font-weight: 600; letter-spacing: 0.08em; }
.radio-tide__sea {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 110px;
transform: translateY(var(--radio-tide-y));
transition: transform 1.4s cubic-bezier(0.45, 0, 0.55, 1);
}
.radio-tide__water { position: absolute; left: 0; right: 0; top: 4px; bottom: 0; background: linear-gradient(color-mix(in oklab, light-dark(#6d7179, #7d8087) 75%, transparent), color-mix(in oklab, light-dark(#dbdde1, #2e3035) 85%, transparent)); }
.radio-tide__wave { position: absolute; left: 0; top: 0; width: 260px; height: 8px; animation: radio-tide-swell 2.6s linear infinite; }
.radio-tide__wave path { fill: color-mix(in oklab, light-dark(#6d7179, #7d8087) 75%, transparent); stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.2; }
.radio-tide__buoy { position: absolute; left: 140px; top: -10px; width: 12px; height: 16px; animation: radio-tide-bob 2.2s ease-in-out infinite; }
.radio-tide__buoy::before { content: ''; position: absolute; left: 0; bottom: 0; width: 12px; height: 9px; border-radius: 6px 6px 3px 3px; background: linear-gradient(light-dark(#373a40, #f1f2f3) 50%, light-dark(#5f626a, #a7abb2) 50%); }
.radio-tide__buoy::after { content: ''; position: absolute; left: 5px; top: 0; width: 2px; height: 8px; background: light-dark(#50535b, #c6c8cd); }
.radio-tide__opts { display: flex; gap: 4px; padding: 4px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.radio-tide__opt {
position: relative;
padding: 5px 12px;
border-radius: 7px;
color: light-dark(#5f626a, #a7abb2);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: color 0.2s, background-color 0.2s;
}
.radio-tide__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-tide__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.radio-tide__opt:has(.radio-tide__input:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.radio-tide__opt:has(.radio-tide__input:checked) { background: light-dark(#42454c, #e5e6e9); color: light-dark(#fbfcfd, #121417); }
@media (prefers-reduced-motion: reduce) {
.radio-tide * { animation: none !important; transition-duration: 0.01s !important; }
}Goal: Create a "Tide" radio.
Visual behavior: A tide picker: choose low, mid or high and the sea slowly rises or falls against the marks on the pier, with the waves running and a buoy bobbing on top.
Technique:
- Real native radios (visually hidden: absolute, 1x1, opacity 0) inside <label>s; the scene is aria-hidden and every state is read from the checked input with :has().
- The whole sea (water, wave and buoy) is one layer translated by the tide variable on a slow ease-in-out, so it moves like a tide.
- The wave is a wide SVG scrolling on a loop; the buoy rocks on its own. Pure CSS, no script.
Accessibility: A role="radiogroup" with an aria-label around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once; :focus-visible on the input outlines its option. Under prefers-reduced-motion the sea moves straight to the level and is still.
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 "Tide" radio.
Visual behavior: A tide picker: choose low, mid or high and the sea slowly rises or falls against the marks on the pier, with the waves running and a buoy bobbing on top.
Technique:
- Real native radios (visually hidden: absolute, 1x1, opacity 0) inside <label>s; the scene is aria-hidden and every state is read from the checked input with :has().
- The whole sea (water, wave and buoy) is one layer translated by the tide variable on a slow ease-in-out, so it moves like a tide.
- The wave is a wide SVG scrolling on a loop; the buoy rocks on its own. Pure CSS, no script.
Accessibility: A role="radiogroup" with an aria-label around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once; :focus-visible on the input outlines its option. Under prefers-reduced-motion the sea moves straight to the level and is still.
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
A tide picker: choose low, mid or high and the sea slowly rises or falls against the marks on the pier, with the waves running and a buoy bobbing on top.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, continuous, ambient
- Techniques
- css variables, css transition, keyframes, svg
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A role="radiogroup" with an aria-label around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once; :focus-visible on the input outlines its option. Under prefers-reduced-motion the sea moves straight to the level and is still.