Scissor Lift

No dependencies
Code format
React + Tailwind
import { useState, type CSSProperties } from 'react';

const L = 118; // arm length
const STAGES = 3;
const BASE = 168; // top of the chassis
const X0 = 22; // the fixed pivots
const S_MIN = 0.06; // sine of the arm angle, folded
const S_MAX = 0.36; // and fully raised
const SPAN = (STAGES * L * (S_MAX - S_MIN)).toFixed(1);
const RISE = (STAGES * L * S_MIN).toFixed(1);

// Every length comes from one registered number, so the arms, pins, platform and
// tag all move together when it eases. Height is linear in the slider value: the
// sine of the arm angle runs evenly, and the angle is its arcsine.
const css = `
  @property --slider-scissor-lift-v { syntax: '<number>'; inherits: true; initial-value: 0; }
  .slider-scissor-lift { --s: calc(${S_MIN} + var(--slider-scissor-lift-v) * ${(S_MAX - S_MIN).toFixed(2)}); --t: asin(var(--s)); --h: calc(${L}px * var(--s)); --w: calc(${L}px * cos(var(--t))); position: relative; display: grid; grid-template-columns: 170px 1fr; width: 230px; height: 196px; transition: --slider-scissor-lift-v 0.45s cubic-bezier(0.3, 1.15, 0.5, 1); }
  .slider-scissor-lift__rig { position: relative; }
  .slider-scissor-lift__chassis { position: absolute; left: 6px; top: ${BASE}px; width: 152px; height: 14px; border-radius: 4px; background: linear-gradient(light-dark(#5f626a, #a7abb2), light-dark(#cbced3, #393c41)); box-shadow: inset 0 1px 0 light-dark(#50535b, #c6c8cd); }
  .slider-scissor-lift__wheel { position: absolute; top: ${BASE + 10}px; width: 16px; height: 16px; border-radius: 50%; background: radial-gradient(circle, light-dark(#5f626a, #a7abb2) 0 3px, light-dark(#dbdde1, #2e3035) 3.5px); }
  .slider-scissor-lift__arm { position: absolute; left: ${X0}px; width: ${L}px; height: 6px; margin-top: -3px; border-radius: 3px; transform-origin: 3px 50%; background: linear-gradient(light-dark(#50535b, #c6c8cd), light-dark(#6d7179, #7d8087)); box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
  .slider-scissor-lift__arm--up { top: calc(${BASE}px - var(--k) * var(--h)); rotate: calc(-1 * var(--t)); }
  .slider-scissor-lift__arm--down { top: calc(${BASE}px - (var(--k) + 1) * var(--h)); rotate: var(--t); z-index: 1; }
  .slider-scissor-lift__pin { position: absolute; left: calc(${X0 + 3}px + var(--w) / 2 - 4px); top: calc(${BASE}px - (var(--k) + 0.5) * var(--h) - 4px); width: 8px; height: 8px; border-radius: 50%; background: light-dark(#42454c, #e5e6e9); box-shadow: inset 0 0 0 2px light-dark(#6d7179, #7d8087); z-index: 2; }
  /* The platform, its rail and a worker standing on it. */
  .slider-scissor-lift__deck { position: absolute; left: 10px; width: 148px; top: calc(${BASE}px - ${STAGES} * var(--h) - 9px); height: 9px; border-radius: 2px; background: linear-gradient(light-dark(#50535b, #c6c8cd), light-dark(#6d7179, #7d8087)); z-index: 3; }
  .slider-scissor-lift__deck::before { content: ''; position: absolute; left: 2px; right: 2px; bottom: 9px; height: 22px; border: 2.5px solid light-dark(#6d7179, #7d8087); border-bottom: 0; border-radius: 2px 2px 0 0; background: linear-gradient(90deg, transparent 0 calc(50% - 1.25px), light-dark(#6d7179, #7d8087) calc(50% - 1.25px) calc(50% + 1.25px), transparent 0); }
  .slider-scissor-lift__worker { position: absolute; left: 96px; bottom: 9px; width: 12px; height: 32px; }
  .slider-scissor-lift__worker::before { content: ''; position: absolute; left: 1px; bottom: 0; width: 10px; height: 22px; border-radius: 5px 5px 2px 2px; background: light-dark(#42454c, #e5e6e9); }
  .slider-scissor-lift__worker::after { content: ''; position: absolute; left: 1px; top: 0; width: 10px; height: 10px; border-radius: 50%; background: light-dark(#50535b, #c6c8cd); box-shadow: 0 -2px 0 -1px light-dark(#373a40, #f1f2f3); }
  .slider-scissor-lift__crate { position: absolute; left: 30px; bottom: 9px; width: 22px; height: 18px; border-radius: 2px; background: light-dark(#cbced3, #393c41); box-shadow: inset 0 0 0 2px light-dark(#6d7179, #7d8087); }
  /* The height scale; its handle rides level with the deck. */
  .slider-scissor-lift__scale { position: relative; }
  .slider-scissor-lift__line { position: absolute; left: 14px; top: calc(${BASE}px - ${RISE}px - ${SPAN}px - 9px); height: ${SPAN}px; width: 2px; border-radius: 1px; background: light-dark(#dbdde1, #2e3035); }
  .slider-scissor-lift__tick { position: absolute; left: 10px; width: 10px; height: 1px; background: light-dark(#6d7179, #7d8087); }
  .slider-scissor-lift__tick b { position: absolute; left: 14px; top: -6px; color: light-dark(#6d7179, #7d8087); font-size: 9px; font-weight: 600; font-variant-numeric: tabular-nums; }
  .slider-scissor-lift__tag { position: absolute; left: 6px; top: calc(${BASE}px - ${STAGES} * var(--h) - 9px); translate: 0 -50%; padding: 3px 6px; border-radius: 5px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#f3f4f7, #1a1c1f); font-size: 11px; font-weight: 700; white-space: nowrap; font-variant-numeric: tabular-nums; box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.5)); }
  .slider-scissor-lift__tag::before { content: ''; position: absolute; left: -5px; top: 50%; translate: 0 -50%; border: 5px solid transparent; border-left: 0; border-right-color: light-dark(#373a40, #f1f2f3); }
  .slider-scissor-lift:has(.slider-scissor-lift__input:focus-visible) .slider-scissor-lift__tag { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  /* A horizontal range turned a quarter, over the whole lift: drag the deck up and down. */
  .slider-scissor-lift__input { position: absolute; left: 115px; top: calc(${BASE}px - ${RISE}px - ${SPAN}px / 2 - 9px); width: calc(${SPAN}px + 24px); height: 230px; margin: 0; translate: -50% -50%; rotate: -90deg; opacity: 0; cursor: ns-resize; -webkit-appearance: none; appearance: none; background: none; }
  .slider-scissor-lift__input::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 230px; }
  .slider-scissor-lift__input::-moz-range-thumb { width: 24px; height: 230px; border: 0; }
  @media (prefers-reduced-motion: reduce) {
    .slider-scissor-lift { transition: none; }
  }
`;

export function Preview() {
  const [v, setV] = useState(55);
  const metres = (v / 10).toFixed(1);
  return (
    <>
      <style href="kit-sliders-scissor-lift" precedence="kit">
        {css}
      </style>
      <div className="slider-scissor-lift" style={{ '--slider-scissor-lift-v': v / 100 } as CSSProperties}>
        <div className="slider-scissor-lift__rig" aria-hidden="true">
          {Array.from({ length: STAGES }, (_, k) => (
            <span key={k} style={{ '--k': k } as CSSProperties}>
              <i className="slider-scissor-lift__arm slider-scissor-lift__arm--up" />
              <i className="slider-scissor-lift__arm slider-scissor-lift__arm--down" />
              <i className="slider-scissor-lift__pin" />
            </span>
          ))}
          <span className="slider-scissor-lift__deck">
            <span className="slider-scissor-lift__crate" />
            <span className="slider-scissor-lift__worker" />
          </span>
          <span className="slider-scissor-lift__chassis" />
          <span className="slider-scissor-lift__wheel" style={{ left: 16 }} />
          <span className="slider-scissor-lift__wheel" style={{ left: 132 }} />
        </div>
        <div className="slider-scissor-lift__scale">
          <span className="slider-scissor-lift__line" aria-hidden="true" />
          {[0, 2, 4, 6, 8, 10].map((m) => (
            <span key={m} className="slider-scissor-lift__tick" style={{ top: `calc(${BASE}px - ${RISE}px - 9px - ${m / 10} * ${SPAN}px)` }} aria-hidden="true">
              <b>{m}</b>
            </span>
          ))}
          <span className="slider-scissor-lift__tag" aria-hidden="true">
            {metres} m
          </span>
        </div>
        <input className="slider-scissor-lift__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Platform height" aria-valuetext={`${metres} metres`} aria-orientation="vertical" />
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/slider-scissor-lift
Source on GitHub

About this component

Drag the platform up the scale and the scissor arms open stage by stage under it, the sliding ends running along their tracks.

Dependencies
None
Difficulty
Advanced
Triggers
state change, spring
Techniques
css property, css variables, css transition
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native range input named "Platform height" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the lift moves without easing.

More sliders

See all
Swing Ride
Tyre
Abacus
ASCII