TipsBlocks Web Technique Library

TB-0002

Crayon Field

CanvasとVanilla JavaScriptで実装したクレヨン風のPointer Interactionです。ポインターをゆっくり動かすと中央シェイプが縮み、素早く動かすと大きく広がり、Press + Dragでは手描きのような軌跡を残します。

Background Effect Interaction Drag Pointer Move Canvas JavaScript
Interaction
ポインターを動かして速度…
Runtime
Canvas 2D

MOTION PREVIEW

速さで変わる動きを見る。

ゆっくりポインターを動かした時と素早くスイープした時で、中央のクレヨン風シェイプがどの程度伸縮するか、ドラッグ軌跡がどのように残るかを短いプレビューで確認できます。

PREVIEW MEDIA Preview MP4 / Poster を登録すると、ここへ自動表示されます。

LIVE DEMO

速度とドラッグを試す。

ポインターを動かして速度によるシェイプの大きさの変化を確認してください。押したままドラッグすると軌跡を描けます。

HOW TO INTERACT

Drag

ポインターを動かして速度によるシェイプの大きさの変化を確認してください。押したままドラッグすると軌跡を描けます。

ABOUT THIS BLOCK

Crayon Fieldとは

Crayon Fieldは、クレヨンで塗り重ねたような不規則なシェイプと、ポインター操作による変形、ドラッグ軌跡を組み合わせたCanvasインタラクションです。画面中央のシェイプは一定の形を保つのではなく、複数の頂点を時間とともに揺らしながら描画することで、手描きのような微妙な不均一さを作っています。

このBlockの大きな特徴は、ポインターを動かす速度そのものをシェイプの大きさへ反映していることです。ゆっくり動かすと中央の形が小さくなり、素早くスイープすると大きく広がります。さらにポインターがシェイプ内部へ入ると、近い頂点が押し込まれたり横方向へ揺れたりするため、単純な拡大縮小だけではない柔らかな反応になります。

ポインターを押したままドラッグすると軌跡が追加され、時間とともに消えていきます。背景、ポインター、軌跡、中央シェイプまで同じ世界観でまとめているため、インタラクティブなヒーロー背景、イベントやクリエイティブサイトのキービジュアル、実験的なポートフォリオ表現などへ応用できます。

Technology
Canvas / JavaScript
Interaction
Drag / Pointer Move
External Library
None
Reduced Motion
Supported

HOW IT WORKS

Crayon Fieldはどう反応しているのか。

頂点を揺らして作る手描きシェイプ、ポインター速度から計算するスケール、内部判定による変形、Press + Dragの軌跡生成を組み合わせています。

  1. 01

    不規則なシェイプの頂点を作る

    中央シェイプは複数の頂点から構成され、各頂点に角度・半径・揺れ幅を持たせています。時間によるsin・cosの変化を加えることで、完全な円や楕円ではない手描き風の輪郭を作ります。

  2. 02

    ポインター速度を取得する

    pointermoveイベントごとに直前位置との差と経過時間を計算し、イベント時点の移動速度を求めます。滑らかに追従する表示用ポインターとは別に、生の入力速度を保持しています。

  3. 03

    速度からシェイプサイズを決める

    取得した入力速度を0〜1へ正規化し、緩急をつけたカーブへ変換してシェイプの目標スケールを決定します。実装では約72%から148%の範囲で変化します。

  4. 04

    内部への侵入で輪郭を変形する

    ポインターが現在の多角形内部にあるかを判定し、近い頂点ほど押し込み量を大きくします。さらに横方向の揺れを加えることで、触れた場所が柔らかく反応する表現にしています。

  5. 05

    Press + Dragで軌跡を描く

    pointerdownからpointerupまでの位置をTrailとして記録し、Canvas上へクレヨン風の線として描画します。終了した軌跡はlife値を減らしながら徐々に消えるよう管理しています。

CODE

クレヨン風インタラクションのコードを見る。

Canvasのリサイズ、ポインター速度の取得、シェイプ頂点の生成、速度に応じた72〜148%のスケール変化、ドラッグ軌跡の描画などをHTML・CSS・JavaScriptに分けて確認できます。

<canvas id="stage" aria-label="Interactive crayon cursor demo"></canvas>
<div class="ui" aria-hidden="true">
  <div class="brand">TipsBlocks</div>
  <div class="id">Motion Idea 001 · Crayon Field</div>
  <div class="tech">Canvas 2D · Vanilla JavaScript</div>
  <div class="hint">Press + drag · sweep fast / slow</div>
</div>
:root{
    --bg:#f2c84b;
    --ink:#542d20;
    --accent:#ff6a84;
    --cream:#fff3d2;
  }
  *{box-sizing:border-box}
  html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg);font-family:Arial,Helvetica,sans-serif}
  body{touch-action:none;cursor:none}
  canvas{position:fixed;inset:0;width:100%;height:100%;display:block}
  .ui{position:fixed;inset:0;pointer-events:none;color:var(--ink);z-index:2}
  .brand{position:absolute;left:22px;top:18px;font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
  .id{position:absolute;right:22px;top:18px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;opacity:.74}
  .hint{position:absolute;left:50%;bottom:24px;transform:translateX(-50%);font-size:12px;letter-spacing:.08em;text-transform:uppercase;white-space:nowrap;opacity:.78}
  .tech{position:absolute;left:22px;bottom:20px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;opacity:.62}
  @media(max-width:700px){
    .brand{left:14px;top:14px}.id{right:14px;top:14px}.hint{bottom:18px;font-size:10px}.tech{display:none}
  }
(() => {
  'use strict';

  const canvas = document.getElementById('stage');
  const ctx = canvas.getContext('2d', { alpha:false });
  const TAU = Math.PI * 2;
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;

  let W = 0, H = 0, DPR = 1;
  let lastFrame = 0;
  let stopClock = 0;

  const pointer = {
    tx: innerWidth * .5,
    ty: innerHeight * .5,
    x: innerWidth * .5,
    y: innerHeight * .5,
    px: innerWidth * .5,
    py: innerHeight * .5,
    speed: 0,
    down: false,
    inside: false,
    seen: false,
    pressure: .55,
    rawSpeed: 0,
    lastInputX: innerWidth * .5,
    lastInputY: innerHeight * .5,
    lastInputTime: performance.now()
  };

  const palette = {
    bg: '#f2c84b',
    ink: '#542d20',
    cursor: '#ff6a84',
    cursor2: '#ff9eb1',
    pale: '#fff3d2'
  };

  const shape = {
    cx: 0,
    cy: 0,
    rx: 0,
    ry: 0,
    hover: 0,
    targetHover: 0,
    phase: 0,
    scale: .82,
    targetScale: .82,
    nodes: []
  };

  const trails = [];
  let activeTrail = null;

  function resize(){
    DPR = Math.min(devicePixelRatio || 1, 2);
    W = innerWidth;
    H = innerHeight;
    canvas.width = Math.round(W * DPR);
    canvas.height = Math.round(H * DPR);
    canvas.style.width = W + 'px';
    canvas.style.height = H + 'px';
    ctx.setTransform(DPR,0,0,DPR,0,0);

    shape.cx = W * .5;
    shape.cy = H * .49;
    shape.rx = Math.min(W * .27, 410);
    shape.ry = Math.min(H * .25, 235);

    if (!shape.nodes.length) {
      const angles = [-2.82,-2.0,-1.02,-.15,.78,1.66,2.52];
      shape.nodes = angles.map((a,i)=>({
        a,
        r: [0.96,1.08,.9,1.04,.91,1.02,.94][i],
        wob: .12 + i * .013,
        lag: .045 + i*.007
      }));
    }
  }

  function ease(current,target,k){ return current + (target-current)*k; }
  function clamp(v,a,b){ return Math.max(a,Math.min(b,v)); }
  function dist(ax,ay,bx,by){ return Math.hypot(ax-bx, ay-by); }

  function polygonPoints(time){
    const pts = [];
    const hx = pointer.x - shape.cx;
    const hy = pointer.y - shape.cy;
    const hlen = Math.max(1, Math.hypot(hx,hy));
    const pushX = hx/hlen;
    const pushY = hy/hlen;
    const h = shape.hover;

    for(let i=0;i<shape.nodes.length;i++){
      const n = shape.nodes[i];
      const jitter = Math.sin(time*(1.45+i*.07)+i*1.8) * (12 + i*1.6)
                   + Math.cos(time*(.82+i*.03)+i*.71) * 8;
      const sx = Math.cos(n.a) * shape.rx * shape.scale * n.r;
      const sy = Math.sin(n.a) * shape.ry * shape.scale * (1 + (n.r-1)*.35);

      const local = Math.max(0, 1 - dist(shape.cx+sx, shape.cy+sy, pointer.x, pointer.y) / Math.max(shape.rx,shape.ry));
      const indent = h * local * 82;
      const swirl = h * Math.sin(n.a*2.4 + time*2.2) * 30;

      pts.push({
        x: shape.cx + sx + Math.cos(n.a)*jitter - pushX*indent + Math.cos(n.a+Math.PI/2)*swirl,
        y: shape.cy + sy + Math.sin(n.a)*jitter - pushY*indent + Math.sin(n.a+Math.PI/2)*swirl
      });
    }
    return pts;
  }

  function pointInPolygon(x,y,pts){
    let inside = false;
    for(let i=0,j=pts.length-1;i<pts.length;j=i++){
      const xi=pts[i].x, yi=pts[i].y, xj=pts[j].x, yj=pts[j].y;
      const hit=((yi>y)!=(yj>y)) && x < (xj-xi)*(y-yi)/(yj-yi+1e-9)+xi;
      if(hit) inside=!inside;
    }
    return inside;
  }

  function pathSmoothPolygon(pts){
    ctx.beginPath();
    for(let i=0;i<pts.length;i++){
      const p=pts[i], n=pts[(i+1)%pts.length];
      const mx=(p.x+n.x)*.5, my=(p.y+n.y)*.5;
      if(i===0) ctx.moveTo(mx,my);
      ctx.quadraticCurveTo(n.x,n.y,(n.x+pts[(i+2)%pts.length].x)*.5,(n.y+pts[(i+2)%pts.length].y)*.5);
    }
    ctx.closePath();
  }

  function drawCrayonPolygon(pts,time){
    ctx.save();
    pathSmoothPolygon(pts);
    ctx.clip();

    // Dense hand-hatched body; angle and spacing intentionally differ from reference.
    const scaledRx = shape.rx * shape.scale;
    const scaledRy = shape.ry * shape.scale;
    const bounds = {x:shape.cx-scaledRx*1.45, y:shape.cy-scaledRy*1.55, w:scaledRx*2.9, h:scaledRy*3.1};
    ctx.lineCap='round';
    ctx.strokeStyle = palette.ink;
    ctx.globalAlpha=.82;
    ctx.lineWidth=1.15;

    const gap = Math.max(7, Math.min(W,H)/95);
    const slant = .36;
    for(let y=bounds.y-gap*3, row=0; y<bounds.y+bounds.h+gap*3; y+=gap, row++){
      const seed = row*12.9898;
      const wob = Math.sin(seed+time*1.7)*4;
      ctx.beginPath();
      ctx.moveTo(bounds.x-80, y+wob);
      ctx.bezierCurveTo(
        shape.cx-shape.rx*.4, y-18+Math.sin(seed)*8,
        shape.cx+shape.rx*.35, y+18+Math.cos(seed*.7)*9,
        bounds.x+bounds.w+80, y + bounds.w*slant*.04 + wob
      );
      ctx.stroke();
    }

    ctx.globalAlpha=.24;
    ctx.lineWidth=.8;
    for(let x=bounds.x-60,col=0;x<bounds.x+bounds.w+60;x+=gap*2.7,col++){
      ctx.beginPath();
      ctx.moveTo(x,bounds.y-40);
      ctx.lineTo(x+scaledRy*.5,bounds.y+bounds.h+40);
      ctx.stroke();
    }
    ctx.restore();

    // imperfect outline, redrawn a few times to create a dry-media edge
    for(let k=0;k<3;k++){
      ctx.save();
      ctx.translate(Math.sin(k*4.3+time*3.2)*1.3, Math.cos(k*2.7+time*2.6)*1.1);
      pathSmoothPolygon(pts);
      ctx.strokeStyle=palette.ink;
      ctx.globalAlpha=.56-k*.1;
      ctx.lineWidth=1.25+k*.35;
      ctx.stroke();
      ctx.restore();
    }
  }

  function addTrailPoint(x,y){
    if(!activeTrail) return;
    const prev = activeTrail.points[activeTrail.points.length-1];
    if(prev && dist(prev.x,prev.y,x,y) < 2.3) return;
    activeTrail.points.push({
      x,y,
      life:1,
      speed:pointer.speed,
      wob:Math.sin((activeTrail.points.length+1)*1.713)*.7
    });
    if(activeTrail.points.length>520) activeTrail.points.shift();
  }

  function drawTexturedStroke(a,b,width,alpha,seed){
    const dx=b.x-a.x, dy=b.y-a.y;
    const len=Math.hypot(dx,dy);
    if(len<.5) return;
    const nx=-dy/len, ny=dx/len;
    const strands = Math.max(3, Math.min(9, Math.round(width*.45)));

    for(let s=0;s<strands;s++){
      const t = strands===1 ? 0 : s/(strands-1)-.5;
      const off=t*width*.72 + Math.sin(seed*2.3+s*1.9)*1.2;
      ctx.beginPath();
      ctx.moveTo(a.x+nx*off, a.y+ny*off);
      const mx=(a.x+b.x)*.5 + Math.sin(seed+s)*1.8;
      const my=(a.y+b.y)*.5 + Math.cos(seed*.8+s)*1.8;
      ctx.quadraticCurveTo(mx+nx*off*.3,my+ny*off*.3,b.x+nx*off,b.y+ny*off);
      ctx.strokeStyle=palette.ink;
      ctx.globalAlpha=alpha*(.38 + (s%3)*.13);
      ctx.lineWidth=.7 + (s%2)*.35;
      ctx.stroke();
    }
  }

  function drawTrails(){
    ctx.save();
    ctx.lineCap='round';
    ctx.lineJoin='round';
    for(const trail of trails){
      const pts=trail.points;
      for(let i=1;i<pts.length;i++){
        const a=pts[i-1], b=pts[i];
        const life=Math.min(a.life,b.life);
        const speed=(a.speed+b.speed)*.5;
        const width=clamp(3.5+speed*.035,4,19);
        drawTexturedStroke(a,b,width,life,i*.731+trail.seed);
      }
    }
    ctx.restore();
    ctx.globalAlpha=1;
  }

  function drawCursor(time){
    if(!pointer.seen) return;
    const r = 9 + Math.min(26,pointer.speed*.075) + shape.hover*(72 + Math.min(48,pointer.speed*.09));
    const wob = reduced ? 0 : Math.sin(time*6.5)*2.2;
    const R = r+wob;

    ctx.save();
    ctx.translate(pointer.x,pointer.y);

    // clipped diagonal scribble fill
    ctx.beginPath();
    ctx.arc(0,0,R,0,TAU);
    ctx.clip();
    ctx.strokeStyle=palette.cursor;
    ctx.lineWidth=1.1;
    ctx.globalAlpha=.88;
    const gap=5.2;
    for(let y=-R*1.6, i=0;y<R*1.6;y+=gap,i++){
      ctx.beginPath();
      ctx.moveTo(-R*1.8,y+Math.sin(i*2.2+time*2)*1.4);
      ctx.lineTo(R*1.8,y-R*.92+Math.cos(i+time)*1.2);
      ctx.stroke();
    }

    ctx.globalAlpha=.35;
    ctx.strokeStyle=palette.cursor2;
    for(let x=-R*1.5, i=0;x<R*1.5;x+=gap*2.5,i++){
      ctx.beginPath();ctx.moveTo(x,-R*1.4);ctx.lineTo(x+R*.7,R*1.4);ctx.stroke();
    }
    ctx.restore();

    ctx.save();
    ctx.translate(pointer.x,pointer.y);
    ctx.strokeStyle=palette.cursor;
    ctx.lineWidth=1.3;
    ctx.globalAlpha=.82;
    for(let k=0;k<2;k++){
      ctx.beginPath();
      ctx.ellipse(Math.sin(time*2+k)*.9,Math.cos(time*2.6+k)*.9,R-k*.7,R*(.98+k*.01),0,0,TAU);
      ctx.stroke();
    }
    ctx.restore();
    ctx.globalAlpha=1;
  }

  function update(dt,time){
    const follow = reduced ? .22 : .14;
    pointer.x = ease(pointer.x,pointer.tx,follow);
    pointer.y = ease(pointer.y,pointer.ty,follow);
    const frameSpeed = dist(pointer.x,pointer.y,pointer.px,pointer.py) / Math.max(dt, 1/120) / 60;
    pointer.speed = ease(pointer.speed, frameSpeed, .18);
    pointer.px=pointer.x; pointer.py=pointer.y;

    // Background shape size is driven by RAW pointer-event velocity, not the smoothed cursor.
    // This makes the size change obvious: slow movement contracts, fast sweeps expand.
    pointer.rawSpeed *= reduced ? .84 : .91;
    const velocity01 = clamp(pointer.rawSpeed / 1.25, 0, 1);
    const velocityCurve = velocity01 * velocity01 * (3 - 2 * velocity01);
    shape.targetScale = .72 + velocityCurve * .76;   // 72% ... 148%
    shape.scale = ease(shape.scale, shape.targetScale, reduced ? .22 : .16);

    const pts = polygonPoints(time);
    pointer.inside = pointInPolygon(pointer.x,pointer.y,pts);
    shape.targetHover = pointer.inside ? 1 : 0;
    shape.hover = ease(shape.hover,shape.targetHover,reduced?.18:.085);

    if(pointer.down) addTrailPoint(pointer.x,pointer.y);

    for(const trail of trails){
      if(trail!==activeTrail){
        const decay = reduced ? .035 : .012;
        for(const p of trail.points) p.life -= decay;
        while(trail.points.length && trail.points[0].life<=0) trail.points.shift();
      }
    }
    for(let i=trails.length-1;i>=0;i--) if(trails[i].points.length<2 && trails[i]!==activeTrail) trails.splice(i,1);
    return pts;
  }

  function draw(time,pts){
    ctx.globalAlpha=1;
    ctx.fillStyle=palette.bg;
    ctx.fillRect(0,0,W,H);

    drawTrails();
    drawCrayonPolygon(pts,time);
    drawCursor(time);
  }

  function frame(ms){
    const time=ms*.001;
    const dt=Math.min(.05,(ms-lastFrame)/1000||.016);
    lastFrame=ms;

    // A deliberately stepped render cadence gives the interaction a hand-made stop-motion pulse.
    if(!reduced){
      const slot=Math.floor(ms/38);
      if(slot===stopClock){ requestAnimationFrame(frame); return; }
      stopClock=slot;
    }

    const pts=update(dt,time);
    draw(time,pts);
    requestAnimationFrame(frame);
  }

  function onMove(e){
    pointer.seen=true;

    const now = performance.now();
    const dx = e.clientX - pointer.lastInputX;
    const dy = e.clientY - pointer.lastInputY;
    const elapsed = Math.max(1, now - pointer.lastInputTime);
    const eventSpeed = Math.hypot(dx, dy) / elapsed; // px per millisecond

    // React quickly to a fast sweep, but let the value fall away smoothly afterwards.
    pointer.rawSpeed = Math.max(pointer.rawSpeed * .55, eventSpeed);
    pointer.lastInputX = e.clientX;
    pointer.lastInputY = e.clientY;
    pointer.lastInputTime = now;

    pointer.tx=e.clientX;
    pointer.ty=e.clientY;
    if(typeof e.pressure==='number' && e.pressure>0) pointer.pressure=e.pressure;
  }
  function onDown(e){
    pointer.seen=true;
    pointer.down=true;
    pointer.tx=e.clientX; pointer.ty=e.clientY;
    activeTrail={points:[],seed:Math.random()*1000};
    trails.push(activeTrail);
    try{ canvas.setPointerCapture(e.pointerId); }catch(_){}
  }
  function onUp(){
    pointer.down=false;
    const released=activeTrail;
    activeTrail=null;
    if(released && released.points.length<2) trails.splice(trails.indexOf(released),1);
  }

  addEventListener('resize',resize,{passive:true});
  addEventListener('pointermove',onMove,{passive:true});
  addEventListener('pointerdown',onDown,{passive:true});
  addEventListener('pointerup',onUp,{passive:true});
  addEventListener('pointercancel',onUp,{passive:true});
  addEventListener('blur',onUp,{passive:true});

  resize();
  requestAnimationFrame(frame);
})();

USE CASE

背景やヒーローへどう活用できるか。

常に同じ動きを繰り返す背景ではなく、ユーザーの動かし方によって形が変わるため、クリエイティブサイトやイベントサイトなど、触って印象が変化する画面に向いています。

Creative Hero Background

Creative Hero Background

デザインスタジオやクリエイティブサービスのヒーロー背景として、来訪者のポインター速度に反応する大きな抽象シェイプを配置する用途です。

Event / Culture Site

Event / Culture Site

音楽・アート・カルチャーイベントなど、整いすぎたUIよりも手触りや遊びを見せたいサイトの背景演出として利用できます。

Experimental Portfolio

Experimental Portfolio

デザイナーや開発者のポートフォリオで、作品一覧へ入る前のインタラクティブなキービジュアルとして使用する想定です。

TECHNICAL NOTES

実装時に確認しておきたいこと。

Canvasの描画負荷、タッチ操作、Pointer Events、Reduced Motion、ドラッグ時のページスクロールとの関係などを確認してから組み込むBlockです。

Browser

Canvas 2DとPointer Eventsを使用します。実装前に対象ブラウザの現行バージョンで、move / down / up / cancelの挙動を確認してください。

Performance

devicePixelRatioは最大2に制限されています。シェイプ描画、ハッチング、軌跡を毎フレーム更新するため、低性能端末では描画密度や更新頻度の調整余地があります。

Mobile

touch-action:noneを使用し、Pointer Eventsでタッチ入力も受け取る構成です。実サイトへ組み込む場合は、ページスクロールを妨げない配置やタッチ操作範囲を検討してください。

Reduced Motion

prefers-reduced-motion: reduceを検出し、追従速度や軌跡の減衰、フレーム処理などを通常時とは異なる値で動作させています。