SCSSをトークン化してみた. 5〜mixin

2026.01.06 10:00
2026.01.22 13:49
SCSSをトークン化してみた. 5〜mixin

前回はマップトークンを紹介しました。
今回は実際に作ったトークンを使えるようにmixinを作ってみます。

border

// ==================================================
// Mixin: border
// 意味ベースでボーダーを適用する
// - `$direction`: top / right / bottom / left / all(省略時は all)
// - `$color-key`: color-map.scss の borderカテゴリに定義されたキー(例:default, subtle など)
// - `$width`: 太さ(省略時は 1px)
// - `$style`: 線種(省略時は solid)
//
// 使用例:
//   @include border(); 
//     → border: 1px solid #ddd;(すべての辺に default 色)
//
//   @include border('top', 'default');
//     → border-top: 1px solid #ddd;
//
//   @include border('bottom', 'subtle', '2px');
//     → border-bottom: 2px solid #eee;
//
//   @include border('left', 'accent', '4px', 'dashed');
//     → border-left: 4px dashed #0055ff;
// ==================================================


@use 'sass:map';
@use '../foundation/maps/color-map' as *;

@mixin border($direction: all, $color-key: default, $width: 1px, $style: solid) {
  $color: map.get(map.get($color-map, border), $color-key);

  @if $direction == all {
    & {
      border: #{$width} #{$style} #{$color};
    }
  } @else {
    & {
      border-#{$direction}: #{$width} #{$style} #{$color};
    }
  }
}

color

// ==================================================
// Mixin/Function: color
// カラーマップから意味ベースで色を取得する
// ==================================================

@use 'sass:map';
@use '../foundation' as *;

@function color($group, $key) {
  @return map.get(map.get($color-map, $group), $key);
}

// 例:@include color('text', 'base');
@mixin color($group: 'text', $key: 'default', $property: null) {
  // プロパティの自動判定
  @if $property == null {
    @if $group == 'text' {
      $property: color;
    } @else if $group == 'bg' or $group == 'overlay' {
      $property: background-color;
    } @else if $group == 'border' {
      $property: border-color;
    } @else if str-index($key, '-text') {
      $property: color;
    } @else if str-index($key, '-bg') {
      $property: background-color;
    } @else {
      $property: color; // fallback
    }
  }

  $val: color($group, $key);

  @if $val == null {
    @warn "[color] No value found for (#{$group}, #{$key})";
  } @else {
    & {
      #{$property}: $val;
    }
  }
}

focus-ring

// ==================================================
// Mixin: focus-ring
// フォーカスリングの統一スタイル
// ==================================================
// Purpose: キーボードフォーカス時のアウトラインを統一管理
// Usage:
//   .c-button:focus-visible {
//     @include focus-ring();
//   }
//
//   // カスタマイズ例
//   .c-link:focus-visible {
//     @include focus-ring($color: $color-accent, $width: 3, $offset: 4);
//   }
// ==================================================

@use 'sass:map';
@use '../foundation' as *;

@mixin focus-ring($color: currentColor, $width: 2, $offset: 2) {
  outline: rem($width) solid $color;
  outline-offset: rem($offset);
}

gradient

// ==================================================
// mixin/_gradient.scss
// --------------------------------------------------
// gradient-map の値を出力する
// ==================================================

@use 'sass:map';
@use '../foundation' as *;

@mixin gradient($key) {
  $value: map.get($gradient-map, $key);

  @if $value == null {
    @error 'gradient "#{$key}" is not defined in $gradient-map.';
  }

  background-image: $value;
}

position

// ==================================================
// Mixin: position
// --------------------------------------------------
// space-map(xs / sm / md / lg / xl)に定義されたトークンをもとに、
// top/right/bottom/left/inset を意味ベースで適用する mixin。
//
// 目的:
// ・position の数値(top/left など)を直値で散らさない
// ・spacing(余白)とは責務を分け、読みやすさを保つ
// ・space-map の軸(xs/sm/md/lg/xl)と完全に同期させ、xs を死なせない
//
// 使い方:
//   .c-card__badge {
//     position: absolute;
//     @include pos('top', 'sm');
//     @include pos('left', 'sm');
//   }
//
//   .c-overlay {
//     position: absolute;
//     @include pos('inset', 'none');   // 4辺0
//   }
//
//   .c-toast {
//     position: fixed;
//     @include pos('inset-x', 'md');   // left/right
//     @include pos('bottom', 'lg');    // bottom
//   }
//
// 注意:
// ・この mixin は “値の適用” だけを担当する(position: absolute 等は呼び出し側)
// ・mq(sm) を使用するため、mq-map 側に sm が定義されている前提
// ==================================================

@use 'sass:string';
@use 'sass:map';
@use '../foundation' as *;

// direction → property
$pos-property-map: (
  top: top,
  right: right,
  bottom: bottom,
  left: left,
  inset: inset
);

// composite directions
$pos-composite-map: (
  inset-x: (left, right),
  inset-y: (top, bottom)
);

@mixin pos($direction, $token) {
  $key: string.unquote($token);
  $property: map.get($pos-property-map, $direction);
  $composite: map.get($pos-composite-map, $direction);

  @if $property == null and $composite == null {
    @warn "pos: 不正な direction '#{$direction}'";
    @error "pos direction '#{$direction}' は無効です。top/right/bottom/left/inset/inset-x/inset-y を指定してください";
  }

  @if map.get($space-map, $key) == null {
    @warn "pos: space-map に '#{$key}' が見つかりません";
    @error "pos token '#{$key}' が space-map に存在しません";
  }

  $token-map: map.get($space-map, $key);
  $apply-properties: if($composite != null, $composite, ($property,));

  @each $prop in $apply-properties {
    // --------------------------------------
    // Base: xs(最小幅の基準値)
    // --------------------------------------
    & { #{$prop}: map.get($token-map, xs); }

    // --------------------------------------
    // Breakpoints: sm / md / lg / xl
    // --------------------------------------
    @include mq(sm) {
      & { #{$prop}: map.get($token-map, sm); }
    }

    @include mq(md) {
      & { #{$prop}: map.get($token-map, md); }
    }

    @include mq(lg) {
      & { #{$prop}: map.get($token-map, lg); }
    }

    @include mq(xl) {
      & { #{$prop}: map.get($token-map, xl); }
    }
  }
}

radius

// ==================================================
// Mixin: radius
// radius-map に基づいて border-radius を適用する
// 例:@include radius('sm');
// ==================================================

@use 'sass:map';
@use '../foundation' as *;

@mixin radius($key) {
  & {
    border-radius: map.get($radius-map, $key);
  }
}

shadow

// ==================================================
// Mixin: shadow
// shadow-map に基づいて box-shadow を適用する
// セレクタ未確定な mixin 内でも使えるように & {} で囲む
//
// $type: 意味ベースの種類(default / hover / modal / blur など)
// $size: サイズ記号(sm / md / lg)
//
// 例:@include shadow('default', 'md');
//     @include shadow('hover', 'lg');
//     @include shadow('none');
// ==================================================


@use 'sass:map';
@use '../foundation' as *;

@mixin shadow($type, $size: md) {
  @if $type == none {
    & {
      box-shadow: none;
    }
  } @else {
    & {
      $value: map.get(map.get($shadow-map, $type), $size);
      box-shadow: $value;
    }
  }
}

spacing

// ==================================================
// Mixin: spacing
// space-map に定義されたトークンをもとに、
// margin/padding のショートハンドで spacing を適用する mixin。
// 例: @include spacing('mb', 'md');
//      @include spacing('m', 'md'); ← 方向省略(上下左右)
//      @include spacing('px', 'md'); ← 左右のみ
//      @include spacing('py', 'md'); ← 上下のみ
//
// 注意:
// ・space-map の軸(xs / sm / md / lg / xl)と同期させるため、
//   base は xs を適用し、mq(sm) から上書きする
// ・mq(sm) を使用するため、mq-map 側に sm が定義されている前提
// ==================================================

@use 'sass:string';
@use 'sass:map';
@use '../foundation' as *;

// --------------------------------------------------
// spacing用ショートハンド対応表
// --------------------------------------------------
$spacing-property-map: (
  mt: margin-top,
  mb: margin-bottom,
  ml: margin-left,
  mr: margin-right,
  pt: padding-top,
  pb: padding-bottom,
  pl: padding-left,
  pr: padding-right,
  m: margin,
  p: padding,
  gap: gap
);

// px/py/mx/my 用の展開マップ
$spacing-composite-map: (
  px: (padding-left, padding-right),
  py: (padding-top, padding-bottom),
  mx: (margin-left, margin-right),
  my: (margin-top, margin-bottom)
);

@mixin spacing($direction, $token) {
  $key: string.unquote($token);
  $property: map.get($spacing-property-map, $direction);
  $composite: map.get($spacing-composite-map, $direction);

  @if $property == null and $composite == null {
    @warn "spacing: 不正な direction '#{$direction}'";
    @error "指定された方向 '#{$direction}' は無効です。mt, mb, px, py などを指定してください";
  }

  @if map.get($space-map, $key) == null {
    @warn "spacing: space-map に '#{$key}' が見つかりません";
    @error "スペーシングトークン '#{$key}' が space-map に存在しません";
  }

  $token-map: map.get($space-map, $key);
  $apply-properties: if($composite != null, $composite, ($property,));

  @each $prop in $apply-properties {
    // --------------------------------------
    // Base: xs(最小幅の基準値)
    // --------------------------------------
    & { #{$prop}: map.get($token-map, xs); }

    // --------------------------------------
    // Breakpoints: sm / md / lg / xl
    // --------------------------------------
    @include mq(sm) {
      & { #{$prop}: map.get($token-map, sm); }
    }

    @include mq(md) {
      & { #{$prop}: map.get($token-map, md); }
    }

    @include mq(lg) {
      & { #{$prop}: map.get($token-map, lg); }
    }

    @include mq(xl) {
      & { #{$prop}: map.get($token-map, xl); }
    }
  }
}

transition

// ==================================================
// mixin/_transition.scss
// 新しい mixin の実装イメージ
// ==================================================

@use 'sass:map';
@use '../foundation' as *;

// --------------------------------------
// transition-preset
// プリセットのタイミング + default値を適用
// --------------------------------------
@mixin transition-preset($preset-name) {
  $preset: map.get($transition-map, 'preset', $preset-name);
  
  @if not $preset {
    @error "Preset '#{$preset-name}' not found in transition-map";
  }
  
  $duration-key: map.get($preset, 'duration');
  $easing-key: map.get($preset, 'easing');
  $properties: map.get($preset, 'properties');
  
  $duration: map.get($transition-map, 'duration', $duration-key);
  $easing: map.get($transition-map, 'easing', $easing-key);
  
  // transition を生成
  transition-property: $properties;
  transition-duration: $duration;
  transition-timing-function: $easing;
  
  // default 値を適用
  @if map.has-key($preset, 'transform') {
    $transform-default: map.get($preset, 'transform', 'default');
    @if $transform-default {
      transform: $transform-default;
    }
  }
  
  @if map.has-key($preset, 'opacity') {
    $opacity-default: map.get($preset, 'opacity', 'default');
    @if $opacity-default {
      opacity: $opacity-default;
    }
  }
}

// --------------------------------------
// transition-state
// hover/active等の状態値を適用
// --------------------------------------
@mixin transition-state($preset-name, $state) {
  $preset: map.get($transition-map, 'preset', $preset-name);
  
  @if not $preset {
    @error "Preset '#{$preset-name}' not found in transition-map";
  }
  
  // transform があれば適用
  @if map.has-key($preset, 'transform') {
    $transform-value: map.get($preset, 'transform', $state);
    @if $transform-value {
      transform: $transform-value;
    }
  }
  
  // opacity があれば適用
  @if map.has-key($preset, 'opacity') {
    $opacity-value: map.get($preset, 'opacity', $state);
    @if $opacity-value {
      opacity: $opacity-value;
    }
  }
}

typography

// ==================================================
// Mixin: typography
// role(用途)または scale(xs〜7xl)を受け取り、
// ブレークポイント(sm / md / lg / xl)に応じた typography を適用する。
//
// 目的
// --------------------------------------------------
// ・SCSS と Figma を “token基準” で同期させる
// ・font-size だけ当たって line-height が残る…のような事故を防ぐ
// ・用途(role)で指定できるようにして、実装側の迷いを減らす
//
// 設計の考え方(役割分担)
// --------------------------------------------------
// 1) role(用途)→ scale(大きさ)への解決
//   - $font-role-map が担当(例:'body' -> 'md')
//
// 2) 値(唯一ソース)
//   - $font-scale-map が担当
//     font-size / line-height / letter-spacing を 1箇所で管理する
//
// 3) weight(太さ)
//   - $font-weight-map が値の唯一ソース(regular/medium/bold など)
//   - $font-weight-role-map が role への割当を担当
//
// 適用ルール(事故防止)
// --------------------------------------------------
// ・typography() は常に以下を “セットで” 当てる
//   - font-size
//   - line-height
//   - letter-spacing
//   - font-weight(role 指定時)
//   → 文字要素の見た目を mixin で完結させる
//
// ・role 指定時は font-weight-role-map に従い自動で font-weight を付与する
//   → 実装者の判断を減らし、bold の濫用を防ぐ
//
// ・scale 直指定時(例:'3xl')は font-weight を自動付与しない
//   → “プロジェクト都合の例外” を自由に扱えるようにする
//
// オーバーライド方針(重要)
// --------------------------------------------------
// 「値を直書き」して崩すのではなく、token を参照して上書きする。
// 例:subheading を適用しつつ、line-height だけ 'xl' token にする、など。
// → token同期(Figma/SCSS)を壊さず、例外の意図もコード上で明確になる。
//
// ・override で指定できるのは “許可した項目だけ”
//   - line-height / letter-spacing / font-weight
//   → 直書きが散らばるのを防ぎ、変更に強くする
//
// Usage(参考)
// --------------------------------------------------
// 1) role(用途)で指定(推奨)
//   .c-text    { @include typography('body'); }
//   .c-caption { @include typography('caption'); }
//   .c-heading { @include typography('heading'); }
//
// 2) scale を直指定(例外的:プロジェクト都合のヒーローなど)
//   .p-heroTitle {
//     @include typography('3xl'); // weight は自動では付かない
//   }
//
// 3) token参照のオーバーライド(値直書き禁止)
//   // subheading のまま、line-height だけ xl token にする
//   .c-card__title {
//     @include typography('subheading', (line-height: 'xl'));
//   }
//
//   // body のまま、letter-spacing だけ sm token にする
//   .c-text--tight {
//     @include typography('body', (letter-spacing: 'sm'));
//   }
//
//   // role の標準 weight を token 参照で上書き(例:subheading を medium に落とす)
//   .c-kicker {
//     @include typography('subheading', (font-weight: 'medium'));
//   }
//
// 引数
// --------------------------------------------------
// typography($key, $override: null)
//   - $key: role / scale(例: 'body' / 'subheading' / '3xl')
//   - $override: token参照での上書き指定(省略可)
//
//   許可するキー:line-height / letter-spacing / font-weight
//   値:
//     line-height / letter-spacing → 'sm' | 'md' | 'lg' | 'xl'
//       (font-scale-map の breakpoint キー)
//     font-weight → 'regular' | 'medium' | 'bold'
//       (font-weight-map のキー)
//
// 注意(運用ルール)
// --------------------------------------------------
// ・typography() を当てる要素では font-weight / letter-spacing / line-height の直書きはしない
//   → 例外は必ず override(token参照)で表現する
// ・typography() は “文字が実際に入る要素” に当てる(p/span/h系/テキスト用クラスなど)
//
// 前提(foundation 側に定義しておくもの)
// --------------------------------------------------
// - $font-role-map
// - $font-scale-map
// - $font-weight-map
// - $font-weight-role-map
// ==================================================

@use 'sass:string';
@use 'sass:map';
@use '../foundation' as *;

// --------------------------------------
// private: role かどうか判定
// --------------------------------------
@function _is-font-role($key) {
  $k: string.unquote($key);
  @return map.has-key($font-role-map, $k);
}

// --------------------------------------
// private: role -> scale 解決
// --------------------------------------
@function _resolve-font-scale($key) {
  $k: string.unquote($key);

  // role -> scale
  $scale: map.get($font-role-map, $k);
  @if $scale != null {
    @return $scale;
  }

  // scale 直指定
  @return $k;
}

// --------------------------------------
// private: scale + breakpoint から props 取得
// --------------------------------------
@function _font-props($scale, $bp) {
  $scale-map: map.get($font-scale-map, $scale);

  @if $scale-map == null {
    @error "font-scale-mapに '#{$scale}' が見つかりません";
  }

  $props: map.get($scale-map, $bp);

  @if $props == null {
    @error "font-scale-map '#{$scale}' に breakpoint '#{$bp}' がありません";
  }

  @return $props;
}

// --------------------------------------
// private: override(token参照)の解決
// - (line-height: 'xl') のように指定された token を返す
// - 指定がなければ null
// --------------------------------------
@function _override-token($override, $prop) {
  @if $override == null {
    @return null;
  }

  $token: map.get($override, $prop);

  @if $token == null {
    @return null;
  }

  @return string.unquote($token);
}

// --------------------------------------
// private: props を適用(3点セット)
// --------------------------------------
@mixin _apply-font-props($props) {
  font-size: map.get($props, size);
  line-height: map.get($props, line-height);
  letter-spacing: map.get($props, letter-spacing);
}

// --------------------------------------
// private: font-weight を解決して返す
// - override があれば最優先(token参照)
// - role 指定なら role-map から自動解決
// - scale 直指定なら自動では返さない(null)
// --------------------------------------
@function _resolve-font-weight($key, $override: null) {
  // 1) override(最優先)
  $w-override: _override-token($override, font-weight);
  @if $w-override != null {
    $wval: map.get($font-weight-map, $w-override);
    @if $wval == null {
      @error "font-weight-map に '#{$w-override}' が見つかりません";
    }
    @return $wval;
  }

  // 2) role のときだけ自動解決
  @if _is-font-role($key) {
    $k: string.unquote($key);
    $wkey: map.get($font-weight-role-map, $k);

    // role に定義がなければ「何もしない」を許容
    @if $wkey == null {
      @return null;
    }

    $wval: map.get($font-weight-map, $wkey);
    @if $wval == null {
      @error "font-weight-map に '#{$wkey}' が見つかりません(role: #{$k})";
    }

    @return $wval;
  }

  // 3) scale 直指定は自動付与しない
  @return null;
}

// --------------------------------------
// public: typography
// - 2nd 引数で token参照のオーバーライドが可能
//   例)@include typography('subheading', (line-height: 'xl'));
// --------------------------------------
@mixin typography($key, $override: null) {
  $scale: _resolve-font-scale($key);

  // sm(ベース)
  $sm: _font-props($scale, 'sm');
  @include _apply-font-props($sm);

  // font-weight(roleなら自動 / overrideならtoken参照)
  // ※weight はレスポンシブで変えることが少ないので sm で1回だけ適用
  $w: _resolve-font-weight($key, $override);
  @if $w != null {
    font-weight: $w;
  }

  // 英語フォント自動適用('-en' サフィックス判定)
  @if string.index(string.quote($key), '-en') {
    font-family: $font-family-en;
    //font-style: italic;
  }

  // --- token override(sm時点) ---
  $lh-sm: _override-token($override, line-height);
  @if $lh-sm != null {
    line-height: map.get(_font-props($scale, $lh-sm), line-height);
  }

  $ls-sm: _override-token($override, letter-spacing);
  @if $ls-sm != null {
    letter-spacing: map.get(_font-props($scale, $ls-sm), letter-spacing);
  }

  @include mq(md) {
    $md: _font-props($scale, 'md');
    @include _apply-font-props($md);

    // --- token override(md時点) ---
    $lh-md: _override-token($override, line-height);
    @if $lh-md != null {
      line-height: map.get(_font-props($scale, $lh-md), line-height);
    }

    $ls-md: _override-token($override, letter-spacing);
    @if $ls-md != null {
      letter-spacing: map.get(_font-props($scale, $ls-md), letter-spacing);
    }
  }

  @include mq(lg) {
    $lg: _font-props($scale, 'lg');
    @include _apply-font-props($lg);

    // --- token override(lg時点) ---
    $lh-lg: _override-token($override, line-height);
    @if $lh-lg != null {
      line-height: map.get(_font-props($scale, $lh-lg), line-height);
    }

    $ls-lg: _override-token($override, letter-spacing);
    @if $ls-lg != null {
      letter-spacing: map.get(_font-props($scale, $ls-lg), letter-spacing);
    }
  }

  @include mq(xl) {
    $xl: _font-props($scale, 'xl');
    @include _apply-font-props($xl);

    // --- token override(xl時点) ---
    $lh-xl: _override-token($override, line-height);
    @if $lh-xl != null {
      line-height: map.get(_font-props($scale, $lh-xl), line-height);
    }

    $ls-xl: _override-token($override, letter-spacing);
    @if $ls-xl != null {
      letter-spacing: map.get(_font-props($scale, $ls-xl), letter-spacing);
    }
  }
}

z-map

// ==================================================
// Mixin: z
// セマンティックz-index管理マップに基づくz-indexの付与
// 例: @include z('header') => z-index: 100;
// ==================================================

@use 'sass:map';
@use '../foundation' as *;

@mixin z($key) {
  $val: map.get($z-map, $key);
  @if $val == null {
    @warn "[z] No z-index found for `#{$key}`";
  } @else {
    & {
      z-index: $val;
    }
  }
}

mixinは以上です!
次回はこれをどう使うかをやってみます。

今回は以上です!