見た目が10倍洗練される!Vue 3で今すぐ使える文字間隔調整コンポーネントの作り方

こんにちは!
九保すこひです(フリーランスのITコンサルタント、エンジニア)

さてさて、絶賛デザインを勉強中なのですが、ついこの間「聞いてみるとそうだけど、効果は絶大だな」と思うことを学びました。

それは、

文字のポジション

です。

文字のポジションは、大きく3つあります。

  • カーニング:左右の位置調整(特定の文字間)
  • トラッキング:左右の位置調整(すべての文字間)
  • ベースライン:上下の位置調整

たとえば、以下の「Laravel」という文字。
何も問題ないと思いますよね。

しかし、よく見ると、先頭の「L」と「a」の間が広すぎると感じませんか?
また、真ん中の「r」と「a」の間は逆につまりすぎている印象です。

つまり、フォントは組み合わせによって、左右の位置を調整すべき場合があるわけです。

実際に調整してみたのが、こちら。

先ほどよりも、バランスがよくなったと思いませんか🤔
また、ベースライン調整も見てみましょう。

これも何の変哲もない日付表記ですが、注意してみると、ハイフンが下に寄りすぎています。これも調整してみましょう。

はい!上下中央にあったほうが見やすいですね。

このように「文字のポジション」はデザインの印象をガラリを変える力を持っているのですが、残念ながらCSSでは細かな調整をすることはできません。

そこで❗

今回は、1文字ごとに「文字ポジション」を設定できる機能をVue 3TypeScript)でつくってみたいと思います。

この記事は以下のような方に向けて書いています。

  • 何度フォントを変えてみても、毎回なんか微妙
  • Google日本語フォントでロゴを表示してるけど、なぜかダサい
  • 画像だと変更する場合に面倒だから、文字はコード上で管理したい
  • Vue 3で便利な機能をつくりたい
  • とにかく面白そうな情報がほしい

ぜひ最後まで読んでくださいね!

「TypeScriptさん、もうちょっと
融通きかせてくれませんか…」

実装する作戦

※もし実際のコードを見たい方は読み飛ばしてください。

今回、文字ポジションを調整するにあたって使うテクニックは、以下2つです。

  • 文字サイズは「見えないspanタグ」を作って計測する
  • 1文字ごとに「position: absolute」を使って絶対位置で表示する

ちなみに「カーニング(特定の文字間)」と「トラッキング(全体の文字間)」は基本的に同じなので、今回はカーニングのみ有効にします。

では、実際のコードを見てみましょう!

実際のコード

実行環境は以下のとおりです。

  • Vue 3:Composition APIを使用
  • TypeScriptを使用
  • Laravel 12.x + Inertia

なお、Vueコンポーネントとして使いまわしできるように実装しています。
では、どうぞ!

コンポーネント部分

TextPosition.vue

<script setup lang="ts">
import { onMounted, ref } from 'vue';

// Common
type PositionItemKey =
| 'index'
| 'fontSize'
| 'fontFamily'
| 'kerning'
| 'baseLine';
interface PositionItem {
index: number;
fontSize?: number;
fontFamily?: string;
kerning?: number;
baseLine?: number;
}
const props = defineProps<{
text: string;
positionItems: PositionItem[];
}>();

// Text position
interface TextSize {
width: number;
height: number;
}
const textRef = ref<HTMLElement | null>(null);
let currentX = 0;
let maxHeight = 0;
const getTextSize = (
char: string,
fontSize: string,
fontFamily: string,
): TextSize => {
const span = document.createElement('span');
span.style.visibility = 'hidden';
span.style.position = 'absolute';
span.style.whiteSpace = 'nowrap';
if (fontSize) span.style.fontSize = `${fontSize}px`;
if (fontFamily) span.style.fontFamily = fontFamily;
span.textContent = char;
document.body.appendChild(span);
const width = span.offsetWidth;
const height = span.offsetHeight;
document.body.removeChild(span);
return { width, height };
};
const getStyleString = (
key: PositionItemKey,
positionItem: PositionItem | undefined,
): string => {
if (textRef.value) {
const styleValue =
positionItem && key in positionItem
? positionItem[key]
: window.getComputedStyle(textRef.value).getPropertyValue(key);
return String(styleValue);
}
return '';
};
const getStyleNumber = (
key: PositionItemKey,
positionItem: PositionItem | undefined,
): number => {
if (textRef.value) {
const styleValue =
positionItem && key in positionItem
? positionItem[key]
: window.getComputedStyle(textRef.value).getPropertyValue(key);
return Number(styleValue);
}
return 0;
};
const addText = (char: string, positionItem: PositionItem | undefined) => {
if (textRef.value) {
const fontSize = getStyleString('fontSize', positionItem);
const fontFamily = getStyleString('fontFamily', positionItem);
const kerning = getStyleNumber('kerning', positionItem);
const baseLine = getStyleNumber('baseLine', positionItem);
const textSize = getTextSize(char, fontSize, fontFamily);
let newX = currentX;
let newY = baseLine;

const textElement = document.createElement('span');
textElement.textContent = char;
textElement.style.position = 'absolute';
textElement.style.fontSize = `${fontSize}px`;
textElement.style.fontFamily = fontFamily;
textElement.style.left = `${newX}px`;
textElement.style.top = `${newY}px`;
textElement.style.lineHeight = `${textSize.height}px`;
textRef.value.appendChild(textElement);

currentX += textSize.width + kerning;

const newHeight = textSize.height + baseLine;
maxHeight = Math.max(maxHeight, newHeight);
}
};
onMounted(() => {
const text = props.text;
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
const positionItem = props.positionItems.find(
(item) => item.index === i,
);
addText(char, positionItem);
}
if (textRef.value) {
textRef.value.style.width = `${currentX}px`;
textRef.value.style.height = `${maxHeight}px`;
}
});
</script>

<template>
<div class="text" ref="textRef"></div>
</template>

<style scoped>
.text {
position: relative;
}
</style>

では中身についてです。

※ちなみに、お仕事ではTypeScriptも使いますが、ブログで書くとなるとコードがごちゃつき過ぎて、あまり向いてないでしょうか🤔この理由から、LinuxつくったリーナスさんもTypeScriptは好きじゃないらしいですね。

流れは以下のとおりです。

  1. パラメータとして入ってきたテキストを1文字ごとに分割する
  2. 分割した位置に該当するポジション情報を取得(なければデフォルト値をつかう)
  3. 該当する位置に配置していく

このとき重要なのが、1文字ごとに(配置すべき)X位置がふえていくという点です。そのため、currentXは常に数値が増えていくようになっています。

逆にY位置は、毎回0を基準にして計算をするようになっていますが、同時に最大の高さ「maxHeight」を更新するようにしています。

なぜなら、親要素に「position: relative」、子要素に「position: absolute」を使うと高さが0になってしまうので、最後にmaxHeightで親要素の高さ調整をする必要があるためです。

使い方

<script setup lang="ts">
import TextPosition from '@/Components/TextPosition.vue';
import { ref } from 'vue';

const text = ref('2025-4-1からは学んだことをOutputしていくぞ!');
const positionItems = ref([
{ index: 0, kerning: 0, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 1, kerning: 0, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 2, kerning: 0, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 3, kerning: 2, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 4, kerning: 0, fontSize: 20, fontFamily: 'Roboto', baseLine: -3 }, // ハイフン
{ index: 5, kerning: 2, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 6, kerning: -1, fontSize: 20, fontFamily: 'Roboto', baseLine: -3 }, // ハイフン
{ index: 7, kerning: 0, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 10, kerning: 3 },
{ index: 16, kerning: 1 },
{ index: 17, kerning: -1, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 18, kerning: -1, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 19, kerning: -1, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 20, kerning: -1, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 21, kerning: -1, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 22, kerning: 0, fontSize: 18, fontFamily: 'Roboto', baseLine: -1 }, // アルファベット
{ index: 23, kerning: -1 },
{ index: 25, kerning: -1 },
{ index: 26, kerning: -2 },
{ index: 27, kerning: -4 },
{ index: 28, kerning: 2, fontSize: 17, baseLine: -1 },
]);
</script>

<template>
<div class="p-10 mixed-font">
<div class="mb-3 inline-block border p-2">{{ text }}</div>
<br />
<div class="inline-block border p-2">
<TextPosition :text="text" :position-items="positionItems" />
</div>
</div>
</template>

<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100..900&family=Roboto:ital,wght@0,100..900;1,100..900&display=swap');

.mixed-font {
font-family: 'Noto Sans JP', 'Roboto';
}
</style>

コードを見ていただくとわかるとおり、1文字ずつ以下パラメータをセットすることで文字ポジションを変更することができます。

  • index: 0から始まる文字の番号(必須)
  • fontSize:文字サイズ
  • fontFamily:フォント名
  • kerning:右側の空白を調整。マイナスもOK!
  • baseLine:上下位置の調整。マイナスもOK!

なお、特に指定するものがない場合は省略することもできます。

※ちなみに、アルファベットと日本語が混ざった状態の場合、だいたいアルファベットの文字サイズが小さくなってしまうそうです。

そのへんも「見た目が同じになるように」調整するといいですね。

テストしてみる

では実際にテストしてみましょう!
まずは、何もしない文章はこちら。

さっきも書きましたが、日本語に比べてアルファベットのサイズが小さくなっていたり、ハイフンが若干下がっていますね。

では、コンポーネントをつかった結果を見てみましょう。

・・・・・・どうなるでしょうか!?

はい!
コンポーネントを使ったほうが可読性が上がっていると思います。

これを使えばGoogleフォントを使ってロゴ文字を表示しているときに「うーん、なんだか微妙…😅」となっても調整してスタイリッシュ化することができますね。

企業様へのご提案

今回のように、プログラムだけでなくデザインなども含めたシステムの構築を心がけています。

もし、より効率的なシステムをご希望でしたら、お気軽にお問い合わせからご連絡くださいね。

お待ちしております😊✨

おわりに

ということで、今回は「文字ポジションを調整できるコンポーネント」を作ってみました。

正直なところ、改行まで考えると複雑になりすぎてしまうので今回はスルーしましたが、ループする部分を改変すれば改行も実装できるとおもいます。

また、ホントに「ちょっとしたこと」で劇的に雰囲気が変わったりするので、デザインの奥深さを体感している状態です。

プログラマといえども、マーケティングやデザイン、SNSなど複数領域の特技をもっていると幅広い活動ができますね。

ぜひ皆さんも、ドラクエで新しい装備をゲットする感覚で武器を増やしていってくださいね。(ただし、全部ヒノキの棒レベルじゃだめですよ!)

ではでは〜❗

「そろそろ電動トゥクトゥクが
納車されるぞ!」

このエントリーをはてなブックマークに追加       follow us in feedly  
お問い合わせ、お待ちしております。
開発のご依頼はこちら: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ