Periscope

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/toggle-periscope
Source on GitHub

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.

More toggles

See all
Puffer
Sail
Shades
Shell