Shredder

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

const MAX = 14;
const STRIPS = [-3.5, 0, 3.5];

const css = `
  .field-shredder { --paper: light-dark(#fbfcfd, #e5e6e9); --ink: light-dark(#373a40, #121417); --body: light-dark(#42454c, #232529); --lit: light-dark(#fbfcfd, #f1f2f3); position: relative; width: 262px; height: 216px; }
  .field-shredder__label { position: absolute; left: 18px; top: 0; color: light-dark(#5f626a, #a7abb2); font: 500 11px/14px system-ui, sans-serif; }
  .field-shredder__slip { position: absolute; left: 18px; top: 18px; width: 194px; height: 32px; border-radius: 3px; background: var(--paper); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--ink) 14%, var(--paper)), 0 2px 4px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)); cursor: text; }
  .field-shredder__slip:has(.field-shredder__input:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
  .field-shredder__empty { position: absolute; left: 14px; top: 0; color: color-mix(in oklab, var(--ink) 55%, var(--paper)); font: 500 13px/32px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; pointer-events: none; }
  .field-shredder__input { position: absolute; inset: 0; z-index: 3; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; background: transparent; color: transparent; caret-color: transparent; font-size: 16px; outline: none; cursor: text; }
  .field-shredder__input::selection { background: transparent; }
  .field-shredder__eye { position: absolute; right: 18px; top: 20px; z-index: 3; display: grid; place-items: center; width: 30px; height: 28px; border: 0; border-radius: 7px; background: light-dark(#ebedf0, #232529); color: light-dark(#50535b, #c6c8cd); cursor: pointer; transition: background-color 0.2s, color 0.2s; }
  .field-shredder__eye:hover { color: light-dark(#373a40, #f1f2f3); }
  .field-shredder__eye:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
  .field-shredder__eye svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
  .field-shredder__eye .field-shredder__slash { stroke-dasharray: 1; stroke-dashoffset: 0; transition: stroke-dashoffset 0.25s; }
  .field-shredder[data-shown] .field-shredder__slash { stroke-dashoffset: 1; }
  /* One tile per character: fed down into the slot, or pulled back up onto the slip. */
  .field-shredder__tile { position: absolute; top: 24px; z-index: 1; width: 13px; height: 22px; color: var(--ink); font: 600 15px/22px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; text-align: center; pointer-events: none; animation: field-shredder-feed 0.8s cubic-bezier(0.5, 0, 0.6, 1) forwards; }
  @keyframes field-shredder-feed {
    0%, 42% { opacity: 1; transform: none; }
    72% { opacity: 1; transform: translateY(40px) scaleY(0.5); }
    100% { opacity: 0; transform: translateY(48px) scaleY(0.2); }
  }
  .field-shredder[data-shown] .field-shredder__tile { animation: field-shredder-pop 0.25s ease-out both; }
  .field-shredder[data-shown] .field-shredder__tile--rise { animation: field-shredder-unshred 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) both; }
  @keyframes field-shredder-unshred { from { opacity: 0; transform: translateY(40px) scaleY(0.4); } }
  @keyframes field-shredder-pop { from { opacity: 0; transform: translateY(-4px); } }
  /* What stays on the slip: a dot per character once its letter has gone through, and the caret. */
  .field-shredder__dot { position: absolute; top: 13px; width: 6px; height: 6px; margin-left: 3.5px; border-radius: 50%; background: var(--ink); pointer-events: none; animation: field-shredder-dot 0.25s cubic-bezier(0.3, 1.6, 0.5, 1) 0.42s both; }
  @keyframes field-shredder-dot { from { opacity: 0; transform: scale(0); } }
  .field-shredder[data-shown] .field-shredder__dot { display: none; }
  .field-shredder__caret { position: absolute; top: 7px; width: 2px; height: 18px; margin-left: 1px; border-radius: 1px; background: var(--ink); opacity: 0; pointer-events: none; transition: left 0.15s ease-out; }
  .field-shredder:has(.field-shredder__input:focus) .field-shredder__caret { animation: field-shredder-blink 1s steps(1) infinite; }
  @keyframes field-shredder-blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
  /* What comes out underneath: three strips per character that drop onto the pile. */
  .field-shredder__strip { position: absolute; margin-left: var(--dx); top: 106px; z-index: 1; width: 2.2px; height: var(--h); border-radius: 1px; background: var(--paper); box-shadow: 0 0 0 0.6px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.22)), 0 1px 1px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); opacity: 0; animation: field-shredder-fall 0.7s cubic-bezier(0.5, 0, 0.8, 0.5) 0.6s forwards; }
  @keyframes field-shredder-fall {
    0% { opacity: 0; transform: translateY(-8px); }
    15% { opacity: 1; }
    100% { opacity: 1; transform: translate(var(--drift), var(--land)) rotate(var(--rot)); }
  }
  .field-shredder[data-shown] .field-shredder__strip { animation: none; opacity: 0; }
  .field-shredder__machine { position: absolute; left: 14px; top: 62px; z-index: 2; width: 234px; height: 46px; border-radius: 6px 6px 3px 3px; background: linear-gradient(var(--body), color-mix(in oklab, var(--body) 80%, light-dark(#fbfcfd, #121417))); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 16%, transparent), 0 8px 12px -6px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); }
  .field-shredder__machine::before { content: ''; position: absolute; left: 14px; right: 14px; top: 6px; height: 4px; border-radius: 2px; background: light-dark(#373a40, #121417); box-shadow: inset 0 1px 2px light-dark(rgba(0, 0, 0, 0.24), rgba(0, 0, 0, 0.8)); }
  .field-shredder__led { position: absolute; right: 14px; bottom: 12px; width: 6px; height: 6px; border-radius: 50%; background: var(--lit); box-shadow: 0 0 6px var(--lit); }
  .field-shredder__brand { position: absolute; left: 16px; bottom: 9px; color: color-mix(in oklab, var(--lit) 55%, transparent); font: 700 8px/1 system-ui, sans-serif; letter-spacing: 0.24em; }
  .field-shredder__bin { position: absolute; left: 28px; top: 108px; width: 206px; height: 84px; border: 1.5px solid light-dark(#cbced3, #393c41); border-top: 0; border-radius: 0 0 10px 10px; background: repeating-linear-gradient(90deg, transparent 0 9px, color-mix(in oklab, light-dark(#cbced3, #393c41) 45%, transparent) 9px 10px); }
  .field-shredder__read { position: absolute; left: 0; right: 0; bottom: 0; color: light-dark(#5f626a, #a7abb2); font: 500 11.5px system-ui, sans-serif; text-align: center; }
  .field-shredder__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
  @media (prefers-reduced-motion: reduce) {
    .field-shredder * { animation-duration: 0.01s !important; animation-delay: 0s !important; transition: none !important; }
  }
`;

export function Preview() {
  const id = useId();
  const [value, setValue] = useState('moonlight7');
  const [shown, setShown] = useState(false);
  // Showing pulls back up only what was already shredded; letters typed after that just appear.
  const [risen, setRisen] = useState(0);
  return (
    <>
      <style href="kit-inputs-shredder" precedence="kit">
        {css}
      </style>
      <div className="field-shredder" data-shown={shown || undefined}>
        <span className="field-shredder__machine" aria-hidden="true">
          <span className="field-shredder__brand">SHRED</span>
          <span className="field-shredder__led" />
        </span>
        <span className="field-shredder__bin" aria-hidden="true" />
        <span className="field-shredder__label" aria-hidden="true">
          Password
        </span>
        <label className="field-shredder__slip" htmlFor={id}>
          {!value && <span className="field-shredder__empty">Type a password</span>}
          {[...value].map((_, i) => (
            <i key={`${i}-${shown}`} className="field-shredder__dot" style={{ left: 10 + i * 13 }} aria-hidden="true" />
          ))}
          <i className="field-shredder__caret" style={{ left: 10 + value.length * 13 }} aria-hidden="true" />
          <input
            id={id}
            className="field-shredder__input"
            type={shown ? 'text' : 'password'}
            autoComplete="new-password"
            maxLength={MAX}
            aria-label="Password"
            value={value}
            onChange={(e) => setValue(e.target.value)}
          />
        </label>
        {[...value].map((ch, i) => (
          <span key={`${i}-${shown}`} className={`field-shredder__tile${i < risen ? ' field-shredder__tile--rise' : ''}`} style={{ left: 28 + i * 13 }} aria-hidden="true">
            {ch}
          </span>
        ))}
        {/* Strips are their own elements, so they stay on the pile after the letter is gone. */}
        {[...value].map((_, i) =>
          STRIPS.map((dx, k) => (
            <i
              key={`${i}-${k}-${shown}`}
              className="field-shredder__strip"
              aria-hidden="true"
              style={{ left: 28 + i * 13 + 6.5, '--dx': `${dx}px`, '--h': `${12 + ((i * 3 + k * 5) % 6)}px`, '--drift': `${((i + k) % 3) - 1}px`, '--land': `${64 - Math.floor(i / 3) * 3 - k}px`, '--rot': `${((i * 7 + k * 13) % 50) - 25}deg` } as CSSProperties}
            />
          )),
        )}
        <button type="button" className="field-shredder__eye" aria-label="Show password" aria-pressed={shown} onClick={() => {
            setRisen(value.length);
            setShown((s) => !s);
          }}>
          <svg viewBox="0 0 24 24" aria-hidden="true">
            <path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
            <circle cx={12} cy={12} r={3} />
            <path className="field-shredder__slash" pathLength={1} d="M4 4L20 20" />
          </svg>
        </button>
        <p className="field-shredder__read" aria-hidden="true">
          <b>{value.length}</b> {value.length === 1 ? 'character' : 'characters'}, {shown ? 'showing' : 'shredded'}
        </p>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/input-shredder
Source on GitHub

About this component

A password field over a paper shredder: each character feeds down into the slot and comes out as strips on the pile, leaving a dot on the slip. Show pulls the letters back out whole.

Dependencies
None
Difficulty
Advanced
Triggers
state change, click, keyframe, focus
Techniques
keyframes, state machine, css variables, transform only
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native password input named "Password" with autocomplete new-password; focus outlines the slip and shows a caret. The eye is a real button named "Show password" with aria-pressed. The tiles, strips, dots and count are aria-hidden. Under prefers-reduced-motion every step happens at once.

More inputs

See all
Sieve

Message in a bottle

Try SAVE20

Coupon
Label maker