SCSSをトークン化してみた. 5〜mixin
2026.01.06 10:00
2026.01.22 13:49
前回はマップトークンを紹介しました。
今回は実際に作ったトークンを使えるように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は以上です!
次回はこれをどう使うかをやってみます。
今回は以上です!