Periscope
import { useState } from 'react';
const css = `
@keyframes toggle-periscope-look { 0%, 100% { transform: scaleX(1); } 30%, 55% { transform: scaleX(-1); } }
@keyframes toggle-periscope-ripple { from { opacity: 0.9; transform: scale(0.3, 0.3); } to { opacity: 0; transform: scale(1.6, 1.2); } }
.toggle-periscope { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
.toggle-periscope__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.toggle-periscope__art { display: block; width: 110px; height: 76px; overflow: hidden; border-radius: 8px; }
.toggle-periscope__input:focus-visible + .toggle-periscope__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.toggle-periscope__water { fill: light-dark(#ebedf0, #232529); }
.toggle-periscope__crest { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 1.4; stroke-linecap: round; }
.toggle-periscope__wave { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.2; stroke-linecap: round; }
.toggle-periscope__tube { fill: light-dark(#6d7179, #7d8087); }
.toggle-periscope__head { fill: light-dark(#5f626a, #a7abb2); }
.toggle-periscope__lens { fill: light-dark(#dbdde1, #2e3035); transition: fill 0.3s; }
.toggle-periscope__ripple { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; opacity: 0; transform-box: fill-box; transform-origin: 50% 50%; }
/* Below the surface the water simply covers it, so rising is one translate. */
.toggle-periscope__mast { transform: translateY(38px); transition: transform 0.6s cubic-bezier(0.5, 0, 0.75, 0.3); }
.toggle-periscope__turn { transform-box: view-box; transform-origin: 51px 0; }
.toggle-periscope__input:checked + .toggle-periscope__art .toggle-periscope__mast { transform: none; transition: transform 0.9s cubic-bezier(0.3, 1.25, 0.5, 1); }
.toggle-periscope__input:checked + .toggle-periscope__art .toggle-periscope__turn { animation: toggle-periscope-look 1.6s ease-in-out 0.9s; }
.toggle-periscope__input:checked + .toggle-periscope__art .toggle-periscope__lens { fill: light-dark(#373a40, #f1f2f3); transition-delay: 0.7s; }
.toggle-periscope[data-armed] .toggle-periscope__ripple { animation: toggle-periscope-ripple 0.9s ease-out var(--d, 0s); }
@media (prefers-reduced-motion: reduce) {
.toggle-periscope__mast, .toggle-periscope__input:checked + .toggle-periscope__art .toggle-periscope__mast { transition: none; }
.toggle-periscope__input:checked + .toggle-periscope__art .toggle-periscope__turn, .toggle-periscope[data-armed] .toggle-periscope__ripple { animation: none; }
}
`;
export function PeriscopeToggle() {
// Each change restarts the ripples where the mast cuts the surface, going up or coming down.
const [flip, setFlip] = useState(0);
return (
<>
<style>
{css}
</style>
<label className="toggle-periscope" data-armed={flip || undefined}>
<input className="toggle-periscope__input" type="checkbox" role="switch" onChange={() => setFlip((f) => f + 1)} />
<svg className="toggle-periscope__art" viewBox="0 0 106 74" aria-hidden="true">
<g className="toggle-periscope__mast">
<rect className="toggle-periscope__tube" x="48" y="14" width="6" height="40" />
<g className="toggle-periscope__turn">
<path className="toggle-periscope__head" d="M45 18V12Q45 8 49 8H58Q60 8 60 10V18Z" />
<rect className="toggle-periscope__lens" x="58" y="9.5" width="4" height="7" rx="1" />
</g>
</g>
<path className="toggle-periscope__water" d="M0 46Q9 43 18 46T36 46T54 46T72 46T90 46T108 46V76H0Z" />
<path className="toggle-periscope__crest" d="M0 46Q9 43 18 46T36 46T54 46T72 46T90 46T108 46" />
<path className="toggle-periscope__wave" d="M12 58q5-2.5 10 0M60 64q5-2.5 10 0M78 55q5-2.5 10 0" />
{flip > 0 && (
<g key={flip}>
<ellipse className="toggle-periscope__ripple" cx="51" cy="47" rx="12" ry="3" />
<ellipse className="toggle-periscope__ripple" style={{ '--d': '0.25s' } as React.CSSProperties} cx="51" cy="47" rx="12" ry="3" />
</g>
)}
</svg>
<span>Watch</span>
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTogglePeriscope(el) on the mounted element instead.
<label class="toggle-periscope">
<input class="toggle-periscope__input" type="checkbox" role="switch"/>
<svg class="toggle-periscope__art" viewBox="0 0 106 74" aria-hidden="true">
<g class="toggle-periscope__mast">
<rect class="toggle-periscope__tube" x="48" y="14" width="6" height="40"></rect>
<g class="toggle-periscope__turn">
<path class="toggle-periscope__head" d="M45 18V12Q45 8 49 8H58Q60 8 60 10V18Z"></path>
<rect class="toggle-periscope__lens" x="58" y="9.5" width="4" height="7" rx="1"></rect>
</g>
</g>
<path class="toggle-periscope__water" d="M0 46Q9 43 18 46T36 46T54 46T72 46T90 46T108 46V76H0Z"></path>
<path class="toggle-periscope__crest" d="M0 46Q9 43 18 46T36 46T54 46T72 46T90 46T108 46"></path>
<path class="toggle-periscope__wave" d="M12 58q5-2.5 10 0M60 64q5-2.5 10 0M78 55q5-2.5 10 0"></path>
</svg>
<span>Watch</span>
</label>@keyframes toggle-periscope-look { 0%, 100% { transform: scaleX(1); } 30%, 55% { transform: scaleX(-1); } }
@keyframes toggle-periscope-ripple { from { opacity: 0.9; transform: scale(0.3, 0.3); } to { opacity: 0; transform: scale(1.6, 1.2); } }
.toggle-periscope { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
.toggle-periscope__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.toggle-periscope__art { display: block; width: 110px; height: 76px; overflow: hidden; border-radius: 8px; }
.toggle-periscope__input:focus-visible + .toggle-periscope__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.toggle-periscope__water { fill: light-dark(#ebedf0, #232529); }
.toggle-periscope__crest { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 1.4; stroke-linecap: round; }
.toggle-periscope__wave { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.2; stroke-linecap: round; }
.toggle-periscope__tube { fill: light-dark(#6d7179, #7d8087); }
.toggle-periscope__head { fill: light-dark(#5f626a, #a7abb2); }
.toggle-periscope__lens { fill: light-dark(#dbdde1, #2e3035); transition: fill 0.3s; }
.toggle-periscope__ripple { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; opacity: 0; transform-box: fill-box; transform-origin: 50% 50%; }
/* Below the surface the water simply covers it, so rising is one translate. */
.toggle-periscope__mast { transform: translateY(38px); transition: transform 0.6s cubic-bezier(0.5, 0, 0.75, 0.3); }
.toggle-periscope__turn { transform-box: view-box; transform-origin: 51px 0; }
.toggle-periscope__input:checked + .toggle-periscope__art .toggle-periscope__mast { transform: none; transition: transform 0.9s cubic-bezier(0.3, 1.25, 0.5, 1); }
.toggle-periscope__input:checked + .toggle-periscope__art .toggle-periscope__turn { animation: toggle-periscope-look 1.6s ease-in-out 0.9s; }
.toggle-periscope__input:checked + .toggle-periscope__art .toggle-periscope__lens { fill: light-dark(#373a40, #f1f2f3); transition-delay: 0.7s; }
.toggle-periscope[data-armed] .toggle-periscope__ripple { animation: toggle-periscope-ripple 0.9s ease-out var(--d, 0s); }
@media (prefers-reduced-motion: reduce) {
.toggle-periscope__mast, .toggle-periscope__input:checked + .toggle-periscope__art .toggle-periscope__mast { transition: none; }
.toggle-periscope__input:checked + .toggle-periscope__art .toggle-periscope__turn, .toggle-periscope[data-armed] .toggle-periscope__ripple { animation: none; }
}function initTogglePeriscope(root) {
var NS = 'http://www.w3.org/2000/svg';
var crest = root.querySelector('.toggle-periscope__crest');
var group = null;
// Each change makes fresh ripples where the mast cuts the surface, going up or coming down.
function onChange() {
root.setAttribute('data-armed', '');
if (group) group.remove();
group = document.createElementNS(NS, 'g');
[0, 0.25].forEach(function (d) {
var e = document.createElementNS(NS, 'ellipse');
e.setAttribute('class', 'toggle-periscope__ripple');
e.setAttribute('cx', '51');
e.setAttribute('cy', '47');
e.setAttribute('rx', '12');
e.setAttribute('ry', '3');
if (d) e.style.setProperty('--d', d + 's');
group.appendChild(e);
});
crest.parentNode.appendChild(group);
}
root.addEventListener('change', onChange);
return function () {
root.removeEventListener('change', onChange);
};
}
document.querySelectorAll('.toggle-periscope').forEach(initTogglePeriscope);Goal: Create a "Periscope" toggle switch.
Visual behavior: A watch switch drawn as a periscope: it rises out of the water with a ripple and turns to look both ways; off lowers it back under with another ripple.
Technique:
- A native checkbox with role="switch", visually hidden but focusable, sits inside a <label> before an aria-hidden drawing and the visible text. Every on state is styled from the input's :checked state through the adjacent sibling combinator, so Space and clicks both work.
- The mast sits in a window cut off at the waterline, so rising is a single translate. The head looks one way then the other with a scaleX keyframe.
- Each change makes a fresh pair of ripple ellipses where the mast meets the surface, so they play going up and coming down.
Accessibility: A native checkbox with role="switch" inside a <label>, so Space toggles it and the visible text names it; :focus-visible on the input outlines the drawing, which is aria-hidden. Under prefers-reduced-motion it rises without ripples or looking round.
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 "Periscope" toggle switch.
Visual behavior: A watch switch drawn as a periscope: it rises out of the water with a ripple and turns to look both ways; off lowers it back under with another ripple.
Technique:
- A native checkbox with role="switch", visually hidden but focusable, sits inside a <label> before an aria-hidden drawing and the visible text. Every on state is styled from the input's :checked state through the adjacent sibling combinator, so Space and clicks both work.
- The mast sits in a window cut off at the waterline, so rising is a single translate. The head looks one way then the other with a scaleX keyframe.
- Each change makes a fresh pair of ripple ellipses where the mast meets the surface, so they play going up and coming down.
Accessibility: A native checkbox with role="switch" inside a <label>, so Space toggles it and the visible text names it; :focus-visible on the input outlines the drawing, which is aria-hidden. Under prefers-reduced-motion it rises without ripples or looking round.
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 watch switch drawn as a periscope: it rises out of the water with a ripple and turns to look both ways; off lowers it back under with another ripple.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, keyframe
- Techniques
- keyframes, css transition, svg
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native checkbox with role="switch" inside a <label>, so Space toggles it and the visible text names it; :focus-visible on the input outlines the drawing, which is aria-hidden. Under prefers-reduced-motion it rises without ripples or looking round.