本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

表示中の文字を押すとその場で入力欄になり、保存すると元の表示に戻る

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

表示中の文字はボタンにしてあり、押すと、同じ所に重ねた入力欄と入れ替えます。入れ替えは一瞬で、字は同じ位置・同じ大きさのまま動かず、周りに枠と地だけが現れるので、表示がそのまま入力欄に変わったように見えます。下には「保存」「キャンセル」の段が開いて、下の項目を押し下げます。保存すると枠が消えて文字が確定し、行の右に「保存しました」が小さく出て消えます。

枠と地は 0.25 秒の power2.out で現れます。ボタンの段は 0.05 秒遅れて、0.35 秒の power3.out で高さ 0 から中身の高さまで開き、ボタンは 10px 上から下ります。閉じるときは枠と段を 0.3 秒で一緒に畳みます。「保存しました」は枠が消えるのを 0.25 秒待ってから出し、1.5 秒見せてから消します。

枠は、文字の外側へ負の inset で広げた疑似要素に描きます。入力欄の側に枠と余白を持たせると、その分だけ字が内側へずれ、押した瞬間に文字が跳ねます。表示のボタンと入力欄は、余白 0・同じ字の組み・同じ行の高さにそろえてあります。複数行の自己紹介は textarea にし、折り返した行が隠れないよう、高さを中身に合わせて伸ばします。

表示のボタンは、項目名と値を続けて読ませます。押すと焦点を入力欄の文の終わりへ移し、Enter で保存、Esc で取り消します。かな漢字変換の途中の Esc は変換の取り消しなので受け流します。閉じたら焦点を表示のボタンへ戻すので、Tab の位置を見失いません。「保存しました」は role が status の要素に入れて読み上げに届け、消えきってから文を空にします。

ラベルが浮く入力欄や、打った語をチップにする入力欄と違い、読むための表示と書き換えるための入力欄を、同じ場所で行き来させる作りです。値はフォームの submit で受けるので、そこへサーバーに送る処理を足します。空で保存したときは前の値を残します。プロフィールやアカウントの設定、管理画面の一覧の値の手直しに向いています。動きを減らす設定では、すぐ切り替えます。

コード

HTML
<section class="profile" aria-labelledby="profile-title" data-saved-text="保存しました">
  <h2 class="profile__title" id="profile-title">アカウント</h2>
  <p id="profile-hint" hidden>押すと、その場で書き換えられます</p>
  <!-- 1 項目: 表示中の文字のボタンと、同じ位置に重ねる入力欄。Enter で送れるよう、欄を form 属性でボタンの段の form に結ぶ -->
  <div class="inline">
    <span class="inline__label" id="p-name">表示名</span>
    <div class="inline__cell">
      <div class="inline__box">
        <button class="inline__view" type="button" aria-labelledby="p-name p-name-text" aria-describedby="profile-hint"><span class="inline__text" id="p-name-text">青木</span></button>
        <input class="inline__input" name="display_name" form="p-name-form" aria-labelledby="p-name" hidden>
      </div>
      <form class="inline__actions" id="p-name-form" hidden><div class="inline__buttons"><button class="inline__save" type="submit">保存</button><button class="inline__cancel" type="button">キャンセル</button></div></form>
      <p class="inline__saved" role="status"></p>
    </div>
  </div>
  <div class="inline">
    <span class="inline__label" id="p-bio">自己紹介</span>
    <div class="inline__cell">
      <div class="inline__box">
        <button class="inline__view" type="button" aria-labelledby="p-bio p-bio-text" aria-describedby="profile-hint"><span class="inline__text" id="p-bio-text">福岡でWebサイトの制作をしています。
休みの日は写真を撮りに出かけます。</span></button>
        <textarea class="inline__input" name="bio" rows="2" form="p-bio-form" aria-labelledby="p-bio" hidden></textarea>
      </div>
      <form class="inline__actions" id="p-bio-form" hidden><div class="inline__buttons"><button class="inline__save" type="submit">保存</button><button class="inline__cancel" type="button">キャンセル</button></div></form>
      <p class="inline__saved" role="status"></p>
    </div>
  </div>
  <div class="inline">
    <span class="inline__label" id="p-area">所在地</span>
    <div class="inline__cell">
      <div class="inline__box">
        <button class="inline__view" type="button" aria-labelledby="p-area p-area-text" aria-describedby="profile-hint"><span class="inline__text" id="p-area-text">福岡県福岡市</span></button>
        <input class="inline__input" name="area" form="p-area-form" aria-labelledby="p-area" hidden>
      </div>
      <form class="inline__actions" id="p-area-form" hidden><div class="inline__buttons"><button class="inline__save" type="submit">保存</button><button class="inline__cancel" type="button">キャンセル</button></div></form>
      <p class="inline__saved" role="status"></p>
    </div>
  </div>
  <div class="inline">
    <span class="inline__label" id="p-mail">メールアドレス</span>
    <div class="inline__cell">
      <div class="inline__box">
        <button class="inline__view" type="button" aria-labelledby="p-mail p-mail-text" aria-describedby="profile-hint"><span class="inline__text" id="p-mail-text">sample@higashiyuki.com</span></button>
        <input class="inline__input" name="email" type="email" autocomplete="email" form="p-mail-form" aria-labelledby="p-mail" hidden>
      </div>
      <form class="inline__actions" id="p-mail-form" hidden><div class="inline__buttons"><button class="inline__save" type="submit">保存</button><button class="inline__cancel" type="button">キャンセル</button></div></form>
      <p class="inline__saved" role="status"></p>
    </div>
  </div>
</section>

CSS
.profile {
  --bg: #111111;                     /* 編集中の欄の地 */
  --surface: #262626;
  --ink: #eeece6;
  --mute: #a8a6a0;                   /* 項目名(16px 未満なので小さい補足の色) */
  --line: rgba(238, 236, 230, 0.14);
  --success: #6ba37f;                /* 「保存しました」 */
  --avatar: #d29b97;                 /* 顔写真の代わりの丸(くすみローズ) */
  --pencil: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4'/%3E%3C/svg%3E");
  --check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12.5l4.5 4.5L19 7.5'/%3E%3C/svg%3E");
  max-width: 592px;
  padding: 24px 32px 8px;
  box-sizing: border-box;
  border-radius: 4px;
  background-color: var(--surface);
  color: var(--ink);
}

.profile__title { display: flex; align-items: center; gap: 16px; margin: 0; padding-bottom: 20px; font-size: 20px; letter-spacing: -0.01em; }
.profile__title::before { content: ""; width: 48px; height: 48px; border-radius: 50%; background-color: var(--avatar); }

.inline {
  display: grid;
  grid-template-columns: 128px minmax(0, 1fr);
  padding: 20px 0;
  box-shadow: inset 0 1px 0 var(--line);
}

@media (max-width: 480px) { .profile { padding-inline: 20px; } .inline { grid-template-columns: minmax(0, 1fr); gap: 8px; } }
.inline__label { color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.04em; line-height: 24px; }
.inline__cell { position: relative; min-width: 0; }

/* 枠と地は、文字の外側へ負の inset で置く。文字の位置は、表示と編集で変わらない */
.inline__box { --edit: 0; position: relative; isolation: isolate; }
.inline__box::before,
.inline__box::after { content: ""; position: absolute; inset: -10px -12px; z-index: -1; border-radius: 4px; }

/* ホバーの地(::before)と、編集の地と枠(::after)。--edit(0〜1)は GSAP が動かす */
.inline__box::before { background-color: color-mix(in srgb, var(--ink) 6%, var(--surface)); opacity: 0; transition: opacity 0.2s; }
.inline__box:has(.inline__view:hover)::before { opacity: 1; }
.inline__box:has(.inline__view:focus-visible)::before { opacity: 1; outline: 2px solid var(--ink); outline-offset: 2px; }
.inline__box::after { background-color: var(--bg); box-shadow: inset 0 0 0 1px var(--ink); opacity: var(--edit); }

.inline__view,
.inline__input {
  display: block;
  box-sizing: border-box;
  width: 100%;
  margin: 0;
  padding: 0 32px 0 0;
  border: 0;
  background: none;
  color: var(--ink);
  font-family: inherit;
  font-size: 16px;
  font-weight: 500;
  letter-spacing: 0.04em;
  line-height: 24px;
  text-align: left;
  outline: none;
}

.inline__view { position: relative; min-height: 24px; white-space: pre-line; overflow-wrap: anywhere; cursor: pointer; }
.inline__input { height: 24px; resize: none; overflow: hidden; caret-color: var(--ink); }
[hidden] { display: none !important; }

/* 鉛筆のしるしはホバーと焦点のときだけ出す */
.inline__view::after {
  content: "";
  position: absolute;
  top: 4px;
  right: 0;
  width: 16px;
  height: 16px;
  background-color: var(--mute);
  mask: var(--pencil) center / contain no-repeat;
  opacity: 0;
  transition: opacity 0.2s;
}

.inline__view:hover::after,
.inline__view:focus-visible::after { opacity: 1; }

.inline__actions { overflow: hidden; }
.inline__buttons { display: flex; gap: 8px; padding-top: 12px; }

.inline__save,
.inline__cancel { height: 40px; padding: 0 20px; border: 0; border-radius: 999px; font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; cursor: pointer; }

.inline__save { background-color: var(--ink); color: var(--bg); }
.inline__cancel { background: none; box-shadow: inset 0 0 0 1px var(--ink); color: var(--ink); }
.inline__save:focus-visible,
.inline__cancel:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

/* 「保存しました」は行の右上に重ね、下の行を押さない */
.inline__saved { position: absolute; top: 0; right: 0; display: flex; align-items: center; gap: 6px; margin: 0; color: var(--success); font-size: 13px; font-weight: 600; letter-spacing: 0.04em; line-height: 24px; opacity: 0; pointer-events: none; }
.inline__saved:not(:empty)::before { content: ""; width: 14px; height: 14px; background-color: currentColor; mask: var(--check) center / contain no-repeat; }

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const d = (seconds) => (reduceMotion ? 0 : seconds); // 動きを減らす設定では、すぐ切り替える
const HOLD = 1.5; // 「保存しました」を見せておく長さ(秒)

document.querySelectorAll(".profile").forEach((profile) => {
  const savedText = profile.dataset.savedText;

  profile.querySelectorAll(".inline").forEach((row) => {
    const box = row.querySelector(".inline__box");
    const view = row.querySelector(".inline__view");
    const text = row.querySelector(".inline__text");
    const input = row.querySelector(".inline__input");
    const form = row.querySelector(".inline__actions");
    const buttons = row.querySelector(".inline__buttons");
    const saved = row.querySelector(".inline__saved");
    let tl;
    let toast;

    // 複数行の欄は、中身に合わせて高さを伸ばす(折り返した行が隠れないように)
    const fit = () => {
      input.style.height = "auto";
      input.style.height = `${input.scrollHeight}px`;
    };

    function open() {
      toast?.progress(1);
      input.value = text.textContent;
      view.hidden = true;
      input.hidden = false;
      form.hidden = false;
      if (input.tagName === "TEXTAREA") fit();
      input.focus();
      input.setSelectionRange(input.value.length, input.value.length); // キャレットは文の終わりに
      tl?.kill();
      tl = gsap.timeline()
        .to(box, { "--edit": 1, duration: d(0.25), ease: "power2.out" }, 0)
        .fromTo(form, { height: 0 }, { height: "auto", duration: d(0.35), ease: "power3.out" }, d(0.05))
        .fromTo(buttons, { y: -10 }, { y: 0, duration: d(0.35), ease: "power3.out" }, d(0.05));
    }

    function close(keep) {
      if (form.hidden) return;
      // 空にして保存したときは、前の値を残す
      if (keep && input.value.trim()) text.textContent = input.value.trim();
      const hadFocus = row.contains(document.activeElement);
      input.hidden = true;
      view.hidden = false;
      if (hadFocus) view.focus(); // 焦点を表示中の文字へ戻し、Tab の位置を失わない
      tl?.kill();
      tl = gsap.timeline({ onComplete: () => (form.hidden = true) })
        .to(box, { "--edit": 0, duration: d(0.3), ease: "power2.out" }, 0)
        .to(form, { height: 0, duration: d(0.3), ease: "power3.inOut" }, 0);
      if (keep) notify();
    }

    // 知らせの文は role="status" の要素に入れて読み上げに届け、消えきってから空にする
    function notify() {
      toast?.kill();
      saved.textContent = savedText;
      toast = gsap.timeline()
        .fromTo(saved, { opacity: 0 }, { opacity: 1, duration: d(0.2), delay: d(0.25) })
        .to(saved, { opacity: 0, duration: d(0.3), delay: HOLD })
        .add(() => (saved.textContent = ""));
    }

    view.addEventListener("click", open);
    if (input.tagName === "TEXTAREA") input.addEventListener("input", fit);
    form.addEventListener("submit", (e) => {
      e.preventDefault(); // 案件では、ここで値をサーバーへ送る
      close(true);
    });
    row.querySelector(".inline__cancel").addEventListener("click", () => close(false));
    input.addEventListener("keydown", (e) => {
      // かな漢字変換の途中の Esc は、変換を取り消すためのもの(Safari は keyCode 229 で見分ける)
      if (e.isComposing || e.keyCode === 229) return;
      if (e.key === "Escape") close(false);
    });
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。